add gigawear auth repository

This commit is contained in:
2026-09-14 18:30:53 +03:00
parent e77046018d
commit f41d7aec33
23 changed files with 254 additions and 41 deletions
+1 -1
View File
@@ -10,7 +10,7 @@ sqldelight {
databases {
create("AppDatabase") {
packageName.set("ru.shadowsparky.gigawear.backend")
dialect("app.cash.sqldelight:postgresql-dialect:2.0.2")
dialect("app.cash.sqldelight:postgresql-dialect:2.3.2")
}
}
}
@@ -6,8 +6,9 @@ import org.koin.core.annotation.Single
import ru.shadowsparky.backend.data.EnvFetcher
import ru.shadowsparky.backend.domain.JwtInfo
import ru.shadowsparky.domain.AppConfig
import ru.shadowsparky.gigawear.backend.di.DbModule
@Module
@Module(includes = [DbModule::class])
@ComponentScan
@Configuration
class BackendModule {
@@ -4,13 +4,17 @@ import io.ktor.http.HttpStatusCode
import io.ktor.serialization.kotlinx.json.json
import io.ktor.server.application.Application
import io.ktor.server.application.install
import io.ktor.server.auth.authenticate
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
import io.ktor.server.response.respond
import io.ktor.server.routing.get
import io.ktor.server.routing.routing
import org.koin.core.annotation.KoinApplication
import org.koin.plugin.module.dsl.startKoin
import ru.shadowsparky.gigawear.backend.presentation.MOCK_USER_ID
import ru.shadowsparky.backend.presentation.AUTH_JWT_NAME
import ru.shadowsparky.backend.presentation.configureJwt
import ru.shadowsparky.backend.presentation.installStatusPages
import ru.shadowsparky.gigawear.backend.presentation.setupAuthRouting
import ru.shadowsparky.gigawear.backend.presentation.setupChat
import ru.shadowsparky.koin
import ru.shadowsparky.updater.backend.presentation.setupUpdates
@@ -25,9 +29,14 @@ fun main(args: Array<String>) {
fun Application.module() {
install(ContentNegotiation) { json(koin.get()) }
configureJwt(koin.get(), koin.get(), koin.get())
installStatusPages()
routing {
get("health") { this.call.respond(HttpStatusCode.OK) }
setupChat(koin.get())
setupUpdates(koin.get()) { MOCK_USER_ID }
setupAuthRouting(koin.get())
authenticate(AUTH_JWT_NAME) {
get("healthCheck") { this.call.respond(HttpStatusCode.OK) }
setupChat(koin.get())
setupUpdates(koin.get())
}
}
}
@@ -0,0 +1,72 @@
package ru.shadowsparky.gigawear.backend.data
import kotlinx.coroutines.withContext
import org.koin.core.annotation.Factory
import ru.shadowsparky.backend.data.JwtPreparer
import ru.shadowsparky.backend.data.StringFetcher
import ru.shadowsparky.backend.data.StringResource
import ru.shadowsparky.backend.data.generateRefreshToken
import ru.shadowsparky.backend.data.toTokenHash
import ru.shadowsparky.backend.domain.LoginVerifier
import ru.shadowsparky.domain.DispatcherProvider
import ru.shadowsparky.gigawear.backend.AppDatabase
import ru.shadowsparky.gigawear.common.AuthRepository
import ru.shadowsparky.gigawear.common.LoginRequest
import ru.shadowsparky.http.domain.BadRequestException
import ru.shadowsparky.http.domain.TokenInfo
import java.time.ZoneOffset
import kotlin.time.Clock
import kotlin.time.Duration.Companion.days
import kotlin.time.toJavaInstant
@Factory
class BackendAuthRepositoryImpl(
appDatabase: AppDatabase,
private val stringFetcher: StringFetcher,
private val loginVerifier: LoginVerifier,
private val jwtPreparer: JwtPreparer,
private val dispatcherProvider: DispatcherProvider
) : AuthRepository {
private val usersQueries = appDatabase.usersQueries
private val tokenQueries = appDatabase.refresh_tokensQueries
override suspend fun login(request: LoginRequest) = withContext(dispatcherProvider.io) {
val userCode = request.code
val user = usersQueries.selectByCode(userCode).executeAsOneOrNull()
?: throw BadRequestException(stringFetcher.get(StringResource.UNKNOWN_USER))
loginVerifier.verify(user.user_id)
create(user.user_id)
}
override suspend fun update(oldTokenInfo: TokenInfo) = withContext(dispatcherProvider.io) {
checkNotNull(oldTokenInfo.refresh)
val tokenHash = oldTokenInfo.refresh!!.toTokenHash(SALT)
val savedToken = tokenQueries.selectByHash(tokenHash)
.executeAsOneOrNull()
?: throw BadRequestException("Token not found")
val now = Clock.System.now().toJavaInstant().atOffset(ZoneOffset.UTC)
if (savedToken.revoked || savedToken.expires_at < now) {
throw BadRequestException("Expired token")
}
tokenQueries.revokeById(savedToken.token_id)
val user = usersQueries.selectById(savedToken.user_id).executeAsOneOrNull()
?: throw BadRequestException(stringFetcher.get(StringResource.UNKNOWN_USER))
create(user.user_id)
}
private suspend fun create(userId: Long): TokenInfo {
val newAccessToken = jwtPreparer.prepare(userId)
val expiresAt = Clock.System.now() + 30.days
val refreshToken = generateRefreshToken()
tokenQueries.insertToken(
user_id = userId,
token_hash = refreshToken.toTokenHash(SALT),
expires_at = expiresAt.toJavaInstant().atOffset(ZoneOffset.UTC)
).await()
return TokenInfo(newAccessToken, refreshToken)
}
private companion object {
const val SALT = "[=TkATMHb!`o}II{=/|!n?06?~z%h=5POwK|\$UUcj,mHI=\$R4<p;!D-=!14|EV%M"
}
}
@@ -1,10 +1,25 @@
package ru.shadowsparky.gigawear.backend.data
import kotlinx.coroutines.withContext
import org.koin.core.annotation.Factory
import ru.shadowsparky.backend.data.StringFetcher
import ru.shadowsparky.backend.data.StringResource
import ru.shadowsparky.backend.domain.LoginVerifier
import ru.shadowsparky.domain.DispatcherProvider
import ru.shadowsparky.gigawear.backend.AppDatabase
import ru.shadowsparky.http.domain.BadRequestException
@Factory
class LoginVerifierImpl : LoginVerifier {
override suspend fun verify(login: String) {
class LoginVerifierImpl(
private val appDatabase: AppDatabase,
private val dispatcherProvider: DispatcherProvider,
private val stringFetcher: StringFetcher
) : LoginVerifier {
override suspend fun verify(userId: Long) {
withContext(dispatcherProvider.io) {
appDatabase.usersQueries.selectById(userId).executeAsOneOrNull()
?: throw BadRequestException(stringFetcher.get(StringResource.UNKNOWN_USER))
}
}
}
@@ -0,0 +1,32 @@
package ru.shadowsparky.gigawear.backend.di
import app.cash.sqldelight.db.SqlDriver
import app.cash.sqldelight.driver.jdbc.asJdbcDriver
import org.koin.core.annotation.Factory
import org.koin.core.annotation.Module
import org.koin.core.annotation.Single
import org.postgresql.ds.PGSimpleDataSource
import ru.shadowsparky.backend.data.EnvFetcher
import ru.shadowsparky.gigawear.backend.AppDatabase
@Module
class DbModule {
@Factory
fun provideSqlDriver(envFetcher: EnvFetcher): SqlDriver {
return PGSimpleDataSource().apply {
val db = envFetcher.get("POSTGRES_DB", "gigawear")
val uri = envFetcher.get("VIDEOBOX_SERVER_URI", "localhost")
val login = envFetcher.get("POSTGRES_USER", "login")
val pass = envFetcher.get("POSTGRES_PASSWORD", "password")
setUrl("jdbc:postgresql://$uri:5432/$db")
user = login
password = pass
}.asJdbcDriver()
}
@Single
fun provideDb(driver: SqlDriver): AppDatabase {
return AppDatabase.invoke(driver)
}
}
@@ -0,0 +1,16 @@
package ru.shadowsparky.gigawear.backend.presentation
import io.ktor.server.request.receive
import io.ktor.server.response.respond
import io.ktor.server.routing.Route
import io.ktor.server.routing.post
import ru.shadowsparky.gigawear.common.AuthRepository
fun Route.setupAuthRouting(authRepository: AuthRepository) {
post(AuthRepository.LOGIN_PATH) {
call.respond(authRepository.login(call.receive()))
}
post(AuthRepository.UPDATE_TOKEN_PATH) {
call.respond(authRepository.update(call.receive()))
}
}
@@ -4,14 +4,13 @@ import io.ktor.server.request.receive
import io.ktor.server.response.respond
import io.ktor.server.routing.Route
import io.ktor.server.routing.post
import ru.shadowsparky.backend.presentation.obtainUserId
import ru.shadowsparky.chat.backend.domain.ProcessUserMessageUseCase
import ru.shadowsparky.chat.domain.Message
const val MOCK_USER_ID = -1L
fun Route.setupChat(useCase: ProcessUserMessageUseCase) {
post("chat") {
val message = call.receive<Message>().copy(timestamp = System.currentTimeMillis())
call.respond(useCase.execute(message, MOCK_USER_ID))
call.respond(useCase.execute(message, call.obtainUserId()))
}
}
@@ -0,0 +1,21 @@
CREATE TABLE refresh_tokens (
token_id int8 NOT NULL GENERATED BY DEFAULT AS IDENTITY,
user_id int8 NOT NULL,
token_hash varchar NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
revoked bool NOT NULL
);
insertToken:
INSERT INTO refresh_tokens (user_id, token_hash, expires_at, revoked)
VALUES (?, ?, ?, FALSE);
selectByHash:
SELECT token_id, user_id, token_hash, expires_at, revoked
FROM refresh_tokens
WHERE token_hash = ?;
revokeById:
UPDATE refresh_tokens
SET revoked = TRUE
WHERE token_id = ?;
@@ -0,0 +1,23 @@
CREATE TABLE "users" (
user_id int8 NOT NULL,
login varchar NOT NULL,
code int,
CONSTRAINT user_id_pk PRIMARY KEY (user_id),
CONSTRAINT login_unique UNIQUE (login),
CONSTRAINT code_unique UNIQUE (code)
);
selectByCode:
SELECT user_id, login, code
FROM "users"
WHERE code = ?;
selectById:
SELECT user_id, login, code
FROM "users"
WHERE user_id = ?;
selectByLogin:
SELECT user_id, login, code
FROM "users"
WHERE login = ?;
@@ -0,0 +1,20 @@
package ru.shadowsparky.gigawear.common
import kotlinx.serialization.Serializable
import ru.shadowsparky.http.domain.TokenInfo
interface AuthRepository {
suspend fun login(request: LoginRequest): TokenInfo
suspend fun update(oldTokenInfo: TokenInfo): TokenInfo
companion object {
const val LOGIN_PATH = "login"
const val UPDATE_TOKEN_PATH = "updateToken"
}
}
@Serializable
data class LoginRequest(
val login: String,
val code: Int
)
+1 -1
View File
@@ -10,7 +10,7 @@ sqldelight {
databases {
create("AppDatabase") {
packageName.set("ru.shadowsparky.vbox.backend")
dialect("app.cash.sqldelight:postgresql-dialect:2.0.2")
dialect("app.cash.sqldelight:postgresql-dialect:2.3.2")
}
}
}
@@ -6,6 +6,7 @@ import ru.shadowsparky.backend.data.EnvFetcher
import ru.shadowsparky.backend.data.JwtPreparer
import ru.shadowsparky.backend.data.StringFetcher
import ru.shadowsparky.backend.data.StringResource
import ru.shadowsparky.backend.data.toTokenHash
import ru.shadowsparky.backend.domain.LoginVerifier
import ru.shadowsparky.domain.Log
import ru.shadowsparky.http.domain.BadRequestException
@@ -17,10 +18,8 @@ import ru.shadowsparky.vbox.shared.domain.AuthTokenRepository
import ru.shadowsparky.vbox.shared.domain.ChangePasswordRequest
import ru.shadowsparky.vbox.shared.domain.TokenRequest
import ru.shadowsparky.vbox.shared.domain.model.LoginInfo
import java.security.MessageDigest
import java.security.SecureRandom
import java.util.concurrent.TimeUnit
import kotlin.io.encoding.Base64
@Factory
class AuthTokenRepositoryFactory(
@@ -131,10 +130,10 @@ class BackendAuthTokenRepository(
}
private suspend fun create(login: String): TokenInfo = withContext(dispatcherProvider.io) {
loginVerifier.verify(login)
val userInfo = userInfoCache.query(login)
?: throw BadRequestException(stringFetcher.get(StringResource.UNKNOWN_USER))
val rsp = jwtPreparer.prepare(login, userInfo.userId)
loginVerifier.verify(userInfo.userId)
val rsp = jwtPreparer.prepare(userInfo.userId)
val refresh = ByteArray(32)
random.nextBytes(refresh)
val refreshStr = refresh.toHexString()
@@ -164,10 +163,7 @@ class BackendAuthTokenRepository(
}
private fun String.toHash(): String {
val md = MessageDigest.getInstance("SHA-256")
md.update(SALT.toByteArray())
val result = md.digest(this.toByteArray())
return Base64.encode(result)
return toTokenHash(SALT)
}
private companion object {
@@ -12,8 +12,8 @@ class BackendLoginVerifier(
private val stringFetcher: StringFetcher,
private val userInfoCache: UserInfoCache
) : LoginVerifier {
override suspend fun verify(login: String) {
val user = userInfoCache.query(login)
override suspend fun verify(userId: Long) {
val user = userInfoCache.query(userId)
?: throw VerifyTokenException(stringFetcher.get(StringResource.UNKNOWN_USER))
user.flags?.let {
if ((it and FLAG_USER_BLOCKED) != 0) {
@@ -30,4 +30,4 @@ class DbModule {
fun provideDb(driver: SqlDriver): AppDatabase {
return AppDatabase.Companion.invoke(driver)
}
}
}
@@ -8,7 +8,7 @@ import ru.shadowsparky.backend.domain.JwtInfo
import ru.shadowsparky.backend.domain.LoginVerifier
import ru.shadowsparky.backend.domain.RemoteEventHandler
import ru.shadowsparky.chat.backend.domain.ProcessUserMessageUseCase
import ru.shadowsparky.vbox.backend.data.BackendUpdateFetcherFactory
import ru.shadowsparky.updater.backend.data.BackendUpdateFetcherFactory
import ru.shadowsparky.vbox.backend.data.auth.AuthTokenRepositoryFactory
import ru.shadowsparky.vbox.backend.data.chat.BackendChatRepositoryFactory
import ru.shadowsparky.vbox.backend.data.tags.MovieTagRepositoryFactory
@@ -6,8 +6,6 @@ import ru.shadowsparky.backend.presentation.configureJwt
import ru.shadowsparky.backend.presentation.obtainUserId
import ru.shadowsparky.vbox.backend.di.AuthEntryPoint
const val AUTH_JWT_NAME = "auth-jwt"
fun Application.configureJwt(authEntryPoint: AuthEntryPoint) = with(authEntryPoint) {
configureJwt(jwtInfo, tokenVerifier, loginVerifier)
}
@@ -7,9 +7,9 @@ import io.ktor.server.response.respond
import io.ktor.server.routing.Routing
import io.ktor.server.routing.get
import io.ktor.server.routing.post
import ru.shadowsparky.backend.presentation.AUTH_JWT_NAME
import ru.shadowsparky.vbox.backend.di.AuthEntryPoint
import ru.shadowsparky.vbox.backend.di.RoutingEntryPoint
import ru.shadowsparky.vbox.backend.presentation.AUTH_JWT_NAME
import ru.shadowsparky.vbox.backend.presentation.obtainUserId
import ru.shadowsparky.vbox.shared.domain.AuthTokenRepository
import ru.shadowsparky.vbox.shared.domain.HealthCheck