diff --git a/backend/authik/build.gradle.kts b/backend/authik/build.gradle.kts new file mode 100644 index 0000000..d8bcff7 --- /dev/null +++ b/backend/authik/build.gradle.kts @@ -0,0 +1,16 @@ +@file:Suppress("UNCHECKED_CAST") + +plugins { + id("buildlogic.foundation-conventions") +} + +dependencies { + implementation(libs.commons.codec.commons.codec) + implementation(project(":foundation")) + testImplementation(project(":foundation-test")) +} + +val serviceName = "authik" + +(extra["generateOAPIServer"] as (String) -> Unit)(serviceName) +(extra["generateJOOQ"] as (String) -> Unit)(serviceName) diff --git a/backend/authik/src/main/kotlin/ru/ifmo/se/dating/authik/api/HttpAuthApi.kt b/backend/authik/src/main/kotlin/ru/ifmo/se/dating/authik/api/HttpAuthApi.kt new file mode 100644 index 0000000..2d64dad --- /dev/null +++ b/backend/authik/src/main/kotlin/ru/ifmo/se/dating/authik/api/HttpAuthApi.kt @@ -0,0 +1,34 @@ +package ru.ifmo.se.dating.authik.api + +import org.springframework.http.ResponseEntity +import org.springframework.stereotype.Controller +import ru.ifmo.se.dating.authik.api.generated.AuthApiDelegate +import ru.ifmo.se.dating.authik.logic.AuthService +import ru.ifmo.se.dating.authik.model.generated.AuthGrantMessage +import ru.ifmo.se.dating.authik.model.generated.TelegramInitDataMessage +import ru.ifmo.se.dating.authik.telegram.InitDataParser +import ru.ifmo.se.dating.exception.AuthenticationException +import ru.ifmo.se.dating.exception.GenericException +import ru.ifmo.se.dating.text.abbreviated + +@Controller +class HttpAuthApi( + private val telegramParser: InitDataParser, + private val auth: AuthService, +) : AuthApiDelegate { + override suspend fun authTelegramWebAppPut( + telegramInitDataMessage: TelegramInitDataMessage, + ): ResponseEntity { + val initData = try { + telegramParser.parse(telegramInitDataMessage) + } catch (error: GenericException) { + throw AuthenticationException( + "Corrupted ${telegramInitDataMessage.string.abbreviated()}: ${error.message}", + error, + ) + } + val token = auth.authenticate(initData) + val response = AuthGrantMessage(access = token.text) + return ResponseEntity.ok(response) + } +} diff --git a/backend/authik/src/main/kotlin/ru/ifmo/se/dating/authik/api/HttpMonitoringApi.kt b/backend/authik/src/main/kotlin/ru/ifmo/se/dating/authik/api/HttpMonitoringApi.kt new file mode 100644 index 0000000..346e7fe --- /dev/null +++ b/backend/authik/src/main/kotlin/ru/ifmo/se/dating/authik/api/HttpMonitoringApi.kt @@ -0,0 +1,20 @@ +package ru.ifmo.se.dating.authik.api + +import kotlinx.coroutines.reactor.awaitSingle +import org.springframework.http.ResponseEntity +import org.springframework.r2dbc.core.DatabaseClient +import org.springframework.stereotype.Controller +import ru.ifmo.se.dating.authik.api.generated.MonitoringApiDelegate + +@Controller +internal class HttpMonitoringApi( + private val data: DatabaseClient, +) : MonitoringApiDelegate { + override suspend fun monitoringHealthcheckGet(): ResponseEntity = + data + .sql("SELECT current_schema") + .map { row, _ -> row.get(0, String::class.java) } + .one() + .map { pong -> ResponseEntity.ok(pong) } + .awaitSingle() +} diff --git a/backend/authik/src/main/kotlin/ru/ifmo/se/dating/authik/logic/AuthService.kt b/backend/authik/src/main/kotlin/ru/ifmo/se/dating/authik/logic/AuthService.kt new file mode 100644 index 0000000..d622ce6 --- /dev/null +++ b/backend/authik/src/main/kotlin/ru/ifmo/se/dating/authik/logic/AuthService.kt @@ -0,0 +1,8 @@ +package ru.ifmo.se.dating.authik.logic + +import ru.ifmo.se.dating.authik.model.generated.TelegramWebAppInitDataMessage +import ru.ifmo.se.dating.security.auth.AccessToken + +interface AuthService { + suspend fun authenticate(telegram: TelegramWebAppInitDataMessage): AccessToken +} diff --git a/backend/authik/src/main/kotlin/ru/ifmo/se/dating/authik/logic/basic/BasicAuthService.kt b/backend/authik/src/main/kotlin/ru/ifmo/se/dating/authik/logic/basic/BasicAuthService.kt new file mode 100644 index 0000000..438ae13 --- /dev/null +++ b/backend/authik/src/main/kotlin/ru/ifmo/se/dating/authik/logic/basic/BasicAuthService.kt @@ -0,0 +1,19 @@ +package ru.ifmo.se.dating.authik.logic.basic + +import org.springframework.stereotype.Service +import ru.ifmo.se.dating.authik.logic.AuthService +import ru.ifmo.se.dating.authik.model.generated.TelegramWebAppInitDataMessage +import ru.ifmo.se.dating.authik.security.auth.JwtTokenIssuer +import ru.ifmo.se.dating.authik.storage.TelegramAccountStorage +import ru.ifmo.se.dating.security.auth.AccessToken + +@Service +class BasicAuthService( + private val telegramAccountStorage: TelegramAccountStorage, + private val issuer: JwtTokenIssuer, +) : AuthService { + override suspend fun authenticate(telegram: TelegramWebAppInitDataMessage): AccessToken { + val userId = telegramAccountStorage.getOrInsert(telegram.user.id) + return issuer.issue(AccessToken.Payload(userId)) + } +} diff --git a/backend/authik/src/main/kotlin/ru/ifmo/se/dating/authik/security/auth/JwtTokenIssuer.kt b/backend/authik/src/main/kotlin/ru/ifmo/se/dating/authik/security/auth/JwtTokenIssuer.kt new file mode 100644 index 0000000..5bbdef8 --- /dev/null +++ b/backend/authik/src/main/kotlin/ru/ifmo/se/dating/authik/security/auth/JwtTokenIssuer.kt @@ -0,0 +1,53 @@ +package ru.ifmo.se.dating.authik.security.auth + +import io.jsonwebtoken.Jwts +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import ru.ifmo.se.dating.security.auth.AccessToken +import ru.ifmo.se.dating.security.auth.Jwt +import ru.ifmo.se.dating.security.key.Keys +import java.security.PrivateKey +import java.time.Clock +import java.util.* +import kotlin.time.toJavaDuration +import kotlin.time.toKotlinDuration +import java.time.Duration as JavaDuration +import kotlin.time.Duration as KotlinDuration + +@Configuration +class JwtTokenIssuerConfiguration { + @Bean + fun jwtTokenIssuer( + clock: Clock, + + @Value("\${security.auth.token.sign.private}") + privateSignKey: String, + + @Value("\${security.auth.token.duration}") + duration: JavaDuration, + ) = JwtTokenIssuer( + clock = clock, + privateSignKey = Keys.deserializePrivate(privateSignKey), + duration = duration.toKotlinDuration(), + ) +} + +class JwtTokenIssuer( + private val clock: Clock, + private val privateSignKey: PrivateKey, + duration: KotlinDuration, +) : TokenIssuer { + private val duration: JavaDuration = duration.toJavaDuration() + + override fun issue(payload: AccessToken.Payload): AccessToken { + val now = clock.instant() + return Jwts.builder() + .claims(Jwt.serialize(payload)) + .issuedAt(Date.from(now)) + .expiration(Date.from(now + duration)) + .signWith(privateSignKey) + .compact() + .let { AccessToken(it) } + } +} diff --git a/backend/authik/src/main/kotlin/ru/ifmo/se/dating/authik/security/auth/TokenIssuer.kt b/backend/authik/src/main/kotlin/ru/ifmo/se/dating/authik/security/auth/TokenIssuer.kt new file mode 100644 index 0000000..f1443d3 --- /dev/null +++ b/backend/authik/src/main/kotlin/ru/ifmo/se/dating/authik/security/auth/TokenIssuer.kt @@ -0,0 +1,7 @@ +package ru.ifmo.se.dating.authik.security.auth + +import ru.ifmo.se.dating.security.auth.AccessToken + +interface TokenIssuer { + fun issue(payload: AccessToken.Payload): AccessToken +} diff --git a/backend/authik/src/main/kotlin/ru/ifmo/se/dating/authik/storage/TelegramAccountStorage.kt b/backend/authik/src/main/kotlin/ru/ifmo/se/dating/authik/storage/TelegramAccountStorage.kt new file mode 100644 index 0000000..801539e --- /dev/null +++ b/backend/authik/src/main/kotlin/ru/ifmo/se/dating/authik/storage/TelegramAccountStorage.kt @@ -0,0 +1,7 @@ +package ru.ifmo.se.dating.authik.storage + +import ru.ifmo.se.dating.security.auth.User + +interface TelegramAccountStorage { + suspend fun getOrInsert(telegramId: Long): User.Id +} diff --git a/backend/authik/src/main/kotlin/ru/ifmo/se/dating/authik/storage/jooq/JooqTelegramAccountStorage.kt b/backend/authik/src/main/kotlin/ru/ifmo/se/dating/authik/storage/jooq/JooqTelegramAccountStorage.kt new file mode 100644 index 0000000..1384b9b --- /dev/null +++ b/backend/authik/src/main/kotlin/ru/ifmo/se/dating/authik/storage/jooq/JooqTelegramAccountStorage.kt @@ -0,0 +1,25 @@ +package ru.ifmo.se.dating.authik.storage.jooq + +import org.jooq.generated.tables.references.TELEGRAM_ACCOUNT +import org.springframework.stereotype.Repository +import ru.ifmo.se.dating.authik.storage.TelegramAccountStorage +import ru.ifmo.se.dating.security.auth.User +import ru.ifmo.se.dating.storage.TxEnv +import ru.ifmo.se.dating.storage.jooq.JooqDatabase + +@Repository +class JooqTelegramAccountStorage( + private val database: JooqDatabase, + private val txEnv: TxEnv, +) : TelegramAccountStorage { + override suspend fun getOrInsert(telegramId: Long): User.Id = txEnv.transactional { + database.maybe { + selectFrom(TELEGRAM_ACCOUNT) + .where(TELEGRAM_ACCOUNT.TELEGRAM_ID.eq(telegramId)) + } ?: database.only { + insertInto(TELEGRAM_ACCOUNT) + .set(TELEGRAM_ACCOUNT.TELEGRAM_ID, telegramId) + .returning() + } + }.let { User.Id(it.accountId!!) } +} diff --git a/backend/authik/src/main/kotlin/ru/ifmo/se/dating/authik/telegram/InitDataParser.kt b/backend/authik/src/main/kotlin/ru/ifmo/se/dating/authik/telegram/InitDataParser.kt new file mode 100644 index 0000000..65d5a70 --- /dev/null +++ b/backend/authik/src/main/kotlin/ru/ifmo/se/dating/authik/telegram/InitDataParser.kt @@ -0,0 +1,55 @@ +package ru.ifmo.se.dating.authik.telegram + +import com.fasterxml.jackson.databind.ObjectMapper +import org.apache.commons.codec.digest.HmacUtils +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Component +import ru.ifmo.se.dating.authik.model.generated.TelegramInitDataMessage +import ru.ifmo.se.dating.authik.model.generated.TelegramWebAppInitDataMessage +import ru.ifmo.se.dating.authik.model.generated.TelegramWebAppUserMessage +import ru.ifmo.se.dating.exception.InvalidValueException +import ru.ifmo.se.dating.validation.expect + +@Component +class InitDataParser( + private val jackson: ObjectMapper, + + @Value("\${security.auth.telegram.token}") + telegramToken: String, +) { + private val algorithm = "HmacSHA256" + private val hmac = + HmacUtils(algorithm, HmacUtils(algorithm, "WebAppData").hmac(telegramToken)) + + fun parse(initData: TelegramInitDataMessage): TelegramWebAppInitDataMessage { + expect(hmac.hmacHex(initData.string) == initData.hash, "hashes are not equal") + + val map = parseToMap(initData) + + val user = jackson.readValue( + map["user"] ?: throw InvalidValueException("no user"), + TelegramWebAppUserMessage::class.java, + ) + + val authDate = map["auth_date"]?.toLongOrNull() + ?: throw InvalidValueException("no auth_date or invalid") + + return TelegramWebAppInitDataMessage( + queryId = map["query_id"], + user = user, + authDate = authDate, + raw = initData.string, + hash = initData.hash, + ) + } + + private fun parseToMap(initData: TelegramInitDataMessage): Map { + val map = mutableMapOf() + for (pair in initData.string.split("\n")) { + val kv = pair.split("=", limit = 2) + expect(kv.size == 2, "syntax error") + map[kv[0]] = kv[1] + } + return map + } +} diff --git a/backend/authik/src/main/resources/application-test.yml b/backend/authik/src/main/resources/application-test.yml new file mode 100644 index 0000000..9f2f4da --- /dev/null +++ b/backend/authik/src/main/resources/application-test.yml @@ -0,0 +1,8 @@ +security: + auth: + token: + sign: + public: RSA:MIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIBigKCAYEArnKw3YcR3WJeLW64J6gc+8dT/ptl4Oi1kdfgib1EQBJmiNVmzgx6hnmf60MhTCbPHeKhbBKzozyFlboO32Aqx5Nfb0UAU2ssl99tuNi8R2VsYby6wkog58GgFidffKohdhWjOZaa3rBNI1D8CQXckk5WW4eFbonB6Vo84OLsebW5CX9ob8bCsJBX2iZYwS+WNCluUMFgxRyaLuyhtyKp0YRa7oje7iu3EXiLnaXTAFhGSP+iK6GxMUPORvGZYfJ7z+tpj6OYQId5cwYD/+5EXFM4wCkq82VDbj99mJqClpHs+1DhPP7sO/aSDM9SONXjAsMTtq27jJgdvEADpd6pHtwv/tHv1PsRS6DiQYFQSx5egc48JEiVDsBkMy3TzOmvf2dAU1KLWImNSwCybnwQiBhoRr2xPuUB6gNwyrUM8gSiX5HfK9pPX2LueberFzBYnzi8yR1phkLlqfvMZn9q6uRp9ysrtsw2tGf+Wn8BlbAoq3W8hD8ufr5pR03zHGvnAgMBAAE= + private: RSA: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 + telegram: + token: fake-telegram-bot-token diff --git a/backend/authik/src/main/resources/application.yml b/backend/authik/src/main/resources/application.yml new file mode 100644 index 0000000..5f2bc00 --- /dev/null +++ b/backend/authik/src/main/resources/application.yml @@ -0,0 +1,20 @@ +spring: + config: + import: application-foundation.yml + datasource: + url: jdbc:postgresql://authik-database:5432/${POSTGRES_DB} + username: ${POSTGRES_USER} + password: ${POSTGRES_PASSWORD} + r2dbc: + url: r2dbc:postgresql://authik-database:5432/${POSTGRES_DB} + username: ${POSTGRES_USER} + password: ${POSTGRES_PASSWORD} +security: + auth: + token: + sign: + public: ${TOKEN_SIGN_KEY_PUBLIC} + private: ${TOKEN_SIGN_KEY_PRIVATE} + duration: PT2H + telegram: + token: ${TELEGRAM_BOT_TOKEN} diff --git a/backend/authik/src/main/resources/database/changelog.sql b/backend/authik/src/main/resources/database/changelog.sql new file mode 100644 index 0000000..12dd8f4 --- /dev/null +++ b/backend/authik/src/main/resources/database/changelog.sql @@ -0,0 +1,13 @@ +--liquibase formatted sql + +--changeset vityaman:initialize +CREATE SCHEMA authik; + +--changeset vityaman:account +CREATE SEQUENCE authik.account_id_seq AS integer START 1; + +CREATE TABLE authik.telegram_account ( + account_id integer PRIMARY KEY DEFAULT nextval('authik.account_id_seq'), + telegram_id bigint NOT NULL UNIQUE, + creation_moment timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP +); diff --git a/backend/authik/src/main/resources/static/openapi/api.yml b/backend/authik/src/main/resources/static/openapi/api.yml new file mode 100644 index 0000000..c7a0790 --- /dev/null +++ b/backend/authik/src/main/resources/static/openapi/api.yml @@ -0,0 +1,227 @@ +openapi: 3.0.3 +info: + title: ITMO Dating Authik + version: 0.0.1 + description: Service is responsible for authentication. + contact: + name: ITMO Dating Team + url: https://github.com/secs-dev/itmo-dating/issues +servers: + - url: /api + x-internal: true +security: + - bearerAuth: [USER, ADMIN] +tags: + - name: Monitoring + - name: Telegram +paths: + /monitoring/healthcheck: + get: + tags: [Monitoring] + summary: Checks if service is alive + description: Returns 'ok', if service is alive, else we will cry + security: [] + responses: + "200": + description: OK + content: + text/html: + schema: + type: string + pattern: ^[a-z]+$ + maxLength: 32 + example: ok + "500": + $ref: "#/components/responses/500" + "503": + $ref: "#/components/responses/503" + default: + $ref: "#/components/responses/Unexpected" + /auth/telegram/web-app: + put: + tags: [Telegram] + summary: Authenticate via Telegram + description: | + Takes data received via the Telegram Mini App, + validates and returns JWT access token + security: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/TelegramInitData" + responses: + "200": + description: Data is valid, identity verified + content: + application/json: + schema: + $ref: "#/components/schemas/AuthGrant" + "400": + $ref: "#/components/responses/400" + "403": + description: User is banned + content: + application/json: + schema: + $ref: "#/components/schemas/GeneralError" + "503": + description: Telegram is unavailable + content: + application/json: + schema: + $ref: "#/components/schemas/GeneralError" + default: + $ref: "#/components/responses/Unexpected" +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + description: Supports RFC8725 + schemas: + TelegramInitData: + description: https://core.telegram.org/bots/webapps#validating-data-received-via-the-mini-app + type: object + properties: + string: + type: string + maxLength: 8192 + hash: + type: string + pattern: ^[A-Fa-f0-9]{64}$ + maxLength: 64 + required: + - string + - hash + TelegramWebAppInitData: + description: https://core.telegram.org/bots/webapps#webappinitdata + type: object + properties: + query_id: + type: string + user: + $ref: "#/components/schemas/TelegramWebAppUser" + auth_date: + type: integer + format: int64 + minimum: 1577826000 + raw: + type: string + minLength: 8192 + hash: + type: string + pattern: ^[A-Fa-f0-9]{64}$ + maxLength: 64 + required: + - user + - auth_date + - raw + - hash + TelegramWebAppUser: + description: https://core.telegram.org/bots/webapps#webappuser + type: object + properties: + id: + type: integer + format: int64 + minimum: 0 + first_name: + type: string + maxLength: 128 + minLength: 1 + last_name: + type: string + maxLength: 128 + minLength: 1 + username: + type: string + maxLength: 128 + minLength: 1 + required: + - id + - first_name + AccessToken: + description: A JWT token required to access resources + type: string + pattern: ^[A-Za-z0-9-_=]+\.[A-Za-z0-9-_=]+\.[A-Za-z0-9-_.+=]*$ + maxLength: 1024 + AuthGrant: + description: Access token + type: object + properties: + access: + $ref: '#/components/schemas/AccessToken' + required: + - access + GeneralError: + type: object + properties: + code: + type: integer + format: int32 + description: HTTP Status Code + minimum: 100 + maximum: 600 + example: 400 + status: + type: string + description: HTTP Status Description + pattern: ^[A-Za-z0-9 .,'-]+$ + maxLength: 64 + example: Bad Request + message: + type: string + description: Detailed Message + pattern: ^[A-Za-z0-9 .,'-]+$ + maxLength: 128 + example: Username must contain only latin letter + required: + - code + - status + - message + responses: + "400": + description: Invalid arguments + content: + application/json: + schema: + $ref: "#/components/schemas/GeneralError" + "401": + description: Authorization error + content: + application/json: + schema: + $ref: "#/components/schemas/GeneralError" + "403": + description: Access forbidden + content: + application/json: + schema: + $ref: "#/components/schemas/GeneralError" + "404": + description: Not found + content: + application/json: + schema: + $ref: "#/components/schemas/GeneralError" + "500": + description: Internal server error + content: + application/json: + schema: + $ref: "#/components/schemas/GeneralError" + "503": + description: Service unavailable + content: + application/json: + schema: + $ref: "#/components/schemas/GeneralError" + Unexpected: + description: Unknown error + content: + application/json: + schema: + $ref: "#/components/schemas/GeneralError" diff --git a/backend/authik/src/test/kotlin/ru/ifmo/se/dating/authik/AuthikStartupTest.kt b/backend/authik/src/test/kotlin/ru/ifmo/se/dating/authik/AuthikStartupTest.kt new file mode 100644 index 0000000..b49e03d --- /dev/null +++ b/backend/authik/src/test/kotlin/ru/ifmo/se/dating/authik/AuthikStartupTest.kt @@ -0,0 +1,10 @@ +package ru.ifmo.se.dating.authik + +import org.junit.Test + +class AuthikStartupTest : AuthikTestSuite() { + @Test + fun contextLoads() { + // Okay + } +} diff --git a/backend/authik/src/test/kotlin/ru/ifmo/se/dating/authik/AuthikTestSuite.kt b/backend/authik/src/test/kotlin/ru/ifmo/se/dating/authik/AuthikTestSuite.kt new file mode 100644 index 0000000..bd94cf6 --- /dev/null +++ b/backend/authik/src/test/kotlin/ru/ifmo/se/dating/authik/AuthikTestSuite.kt @@ -0,0 +1,22 @@ +package ru.ifmo.se.dating.authik + +import org.junit.runner.RunWith +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.test.context.ActiveProfiles +import org.springframework.test.context.ContextConfiguration +import org.springframework.test.context.junit4.SpringRunner +import ru.ifmo.se.dating.Application + +@RunWith(SpringRunner::class) +@ActiveProfiles(profiles = ["test"]) +@SpringBootTest( + classes = [Application::class], + webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT, + useMainMethod = SpringBootTest.UseMainMethod.ALWAYS, +) +@ContextConfiguration( + initializers = [ + DatabaseInitializer::class, + ], +) +abstract class AuthikTestSuite diff --git a/backend/authik/src/test/kotlin/ru/ifmo/se/dating/authik/DatabaseInitializer.kt b/backend/authik/src/test/kotlin/ru/ifmo/se/dating/authik/DatabaseInitializer.kt new file mode 100644 index 0000000..830e9a6 --- /dev/null +++ b/backend/authik/src/test/kotlin/ru/ifmo/se/dating/authik/DatabaseInitializer.kt @@ -0,0 +1,23 @@ +package ru.ifmo.se.dating.authik + +import org.springframework.boot.test.util.TestPropertyValues +import org.springframework.context.ApplicationContextInitializer +import org.springframework.context.ConfigurableApplicationContext +import ru.ifmo.se.dating.container.Postgres + +class DatabaseInitializer : + ApplicationContextInitializer { + private val postgres: Postgres = Postgres.start() + + override fun initialize(ctx: ConfigurableApplicationContext) { + TestPropertyValues.of( + "spring.datasource.url=${postgres.jdbcUrl}", + "spring.datasource.username=${postgres.username}", + "spring.datasource.password=${postgres.password}", + + "spring.r2dbc.url=${postgres.r2dbcUrl}", + "spring.r2dbc.username=${postgres.username}", + "spring.r2dbc.password=${postgres.password}", + ).applyTo(ctx.environment) + } +} diff --git a/backend/authik/src/test/kotlin/ru/ifmo/se/dating/authik/api/HttpMonitoringApiTest.kt b/backend/authik/src/test/kotlin/ru/ifmo/se/dating/authik/api/HttpMonitoringApiTest.kt new file mode 100644 index 0000000..303e977 --- /dev/null +++ b/backend/authik/src/test/kotlin/ru/ifmo/se/dating/authik/api/HttpMonitoringApiTest.kt @@ -0,0 +1,23 @@ +package ru.ifmo.se.dating.authik.api + +import org.junit.Assert +import org.junit.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.web.client.TestRestTemplate +import ru.ifmo.se.dating.authik.AuthikTestSuite + +class HttpMonitoringApiTest : AuthikTestSuite() { + @Autowired + private lateinit var rest: TestRestTemplate + + @Test + fun healthcheck() { + Assert.assertEquals(getHealthcheck(), "public") + } + + private fun getHealthcheck(): String { + val url = "http://localhost:8080/api/monitoring/healthcheck" + val response = rest.getForEntity(url, String::class.java) + return response.body!! + } +} diff --git a/backend/authik/src/test/kotlin/ru/ifmo/se/dating/authik/security/auth/JwtTest.kt b/backend/authik/src/test/kotlin/ru/ifmo/se/dating/authik/security/auth/JwtTest.kt new file mode 100644 index 0000000..f336275 --- /dev/null +++ b/backend/authik/src/test/kotlin/ru/ifmo/se/dating/authik/security/auth/JwtTest.kt @@ -0,0 +1,35 @@ +package ru.ifmo.se.dating.authik.security.auth + +import org.junit.Assert.assertEquals +import org.junit.Test +import ru.ifmo.se.dating.security.auth.AccessToken +import ru.ifmo.se.dating.security.auth.JwtTokenDecoder +import ru.ifmo.se.dating.security.auth.User +import java.security.KeyPairGenerator +import java.time.Clock +import kotlin.time.Duration.Companion.hours + +class JwtTest { + @Test + fun jwtLifecycle() { + val rsa = KeyPairGenerator.getInstance("RSA").genKeyPair() + + val clock = Clock.systemUTC() + + val issuer = JwtTokenIssuer( + clock = clock, + privateSignKey = rsa.private, + duration = 10.hours, + ) + + val decoder = JwtTokenDecoder( + clock = clock, + publicSignKey = rsa.public, + ) + + val expected = AccessToken.Payload(User.Id(123)) + val actual = issuer.issue(expected).let { decoder.decode(it) } + + assertEquals(expected, actual) + } +} diff --git a/backend/buildSrc/src/main/kotlin/buildlogic.oapi-server-conventions.gradle.kts b/backend/buildSrc/src/main/kotlin/buildlogic.oapi-server-conventions.gradle.kts index e17b435..dcba50b 100644 --- a/backend/buildSrc/src/main/kotlin/buildlogic.oapi-server-conventions.gradle.kts +++ b/backend/buildSrc/src/main/kotlin/buildlogic.oapi-server-conventions.gradle.kts @@ -12,7 +12,7 @@ extra["generateOAPIServer"] = { serviceName: String -> generatorName = "kotlin-spring" inputSpec = "$apiResourcesDir/static/openapi/api.yml" outputDir = generatedDir - invokerPackage = "$group.$serviceName" + invokerPackage = "$group" apiPackage = "$group.$serviceName.api.generated" modelPackage = "$group.$serviceName.model.generated" modelNameSuffix = "Message" diff --git a/backend/config/env/.env b/backend/config/env/.env index 4717f5a..6ef47f9 100644 --- a/backend/config/env/.env +++ b/backend/config/env/.env @@ -1,3 +1,10 @@ +ITMO_DATING_TOKEN_SIGN_KEY_PUBLIC="RSA:MIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIBigKCAYEArnKw3YcR3WJeLW64J6gc+8dT/ptl4Oi1kdfgib1EQBJmiNVmzgx6hnmf60MhTCbPHeKhbBKzozyFlboO32Aqx5Nfb0UAU2ssl99tuNi8R2VsYby6wkog58GgFidffKohdhWjOZaa3rBNI1D8CQXckk5WW4eFbonB6Vo84OLsebW5CX9ob8bCsJBX2iZYwS+WNCluUMFgxRyaLuyhtyKp0YRa7oje7iu3EXiLnaXTAFhGSP+iK6GxMUPORvGZYfJ7z+tpj6OYQId5cwYD/+5EXFM4wCkq82VDbj99mJqClpHs+1DhPP7sO/aSDM9SONXjAsMTtq27jJgdvEADpd6pHtwv/tHv1PsRS6DiQYFQSx5egc48JEiVDsBkMy3TzOmvf2dAU1KLWImNSwCybnwQiBhoRr2xPuUB6gNwyrUM8gSiX5HfK9pPX2LueberFzBYnzi8yR1phkLlqfvMZn9q6uRp9ysrtsw2tGf+Wn8BlbAoq3W8hD8ufr5pR03zHGvnAgMBAAE=" + +ITMO_DATING_AUTHIK_POSTGRES_DB="postgres" +ITMO_DATING_AUTHIK_POSTGRES_USER="postgres" +ITMO_DATING_AUTHIK_POSTGRES_PASSWORD="postgres" +ITMO_DATING_AUTHIK_TOKEN_SIGN_KEY_PRIVATE="RSA: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" + ITMO_DATING_MATCHMAKER_POSTGRES_DB="postgres" ITMO_DATING_MATCHMAKER_POSTGRES_USER="postgres" ITMO_DATING_MATCHMAKER_POSTGRES_PASSWORD="postgres" diff --git a/backend/foundation/build.gradle.kts b/backend/foundation/build.gradle.kts index 1a0d59a..e8140ec 100644 --- a/backend/foundation/build.gradle.kts +++ b/backend/foundation/build.gradle.kts @@ -1,6 +1,7 @@ plugins { id("buildlogic.kotlin-library-conventions") id("buildlogic.jooq-conventions") + kotlin("plugin.spring") } dependencies { @@ -17,6 +18,10 @@ dependencies { api(libs.io.swagger.core.v3.swagger.models) api(libs.org.openapitools.jackson.databind.nullable) + api(libs.io.jsonwebtoken.jjwt.api) + runtimeOnly(libs.io.jsonwebtoken.jjwt.impl) + runtimeOnly(libs.io.jsonwebtoken.jjwt.jackson) + api(libs.jakarta.validation.jakarta.validation.api) api(libs.com.fasterxml.jackson.core.jackson.databind) @@ -31,5 +36,6 @@ dependencies { api(libs.org.liquibase.liquibase.core) api(libs.org.postgresql.postgresql) api(libs.org.postgresql.r2dbc.postgresql) -} + testImplementation(libs.junit.junit) +} diff --git a/backend/foundation/src/main/kotlin/ru/ifmo/se/dating/exception/AuthenticationException.kt b/backend/foundation/src/main/kotlin/ru/ifmo/se/dating/exception/AuthenticationException.kt new file mode 100644 index 0000000..c0b4d7a --- /dev/null +++ b/backend/foundation/src/main/kotlin/ru/ifmo/se/dating/exception/AuthenticationException.kt @@ -0,0 +1,4 @@ +package ru.ifmo.se.dating.exception + +class AuthenticationException(message: String, cause: Throwable? = null) : + SecurityException(message, cause) diff --git a/backend/foundation/src/main/kotlin/ru/ifmo/se/dating/exception/SecurityException.kt b/backend/foundation/src/main/kotlin/ru/ifmo/se/dating/exception/SecurityException.kt new file mode 100644 index 0000000..70644d4 --- /dev/null +++ b/backend/foundation/src/main/kotlin/ru/ifmo/se/dating/exception/SecurityException.kt @@ -0,0 +1,4 @@ +package ru.ifmo.se.dating.exception + +abstract class SecurityException(message: String, cause: Throwable? = null) : + GenericException(message, cause) diff --git a/backend/foundation/src/main/kotlin/ru/ifmo/se/dating/security/auth/AccessToken.kt b/backend/foundation/src/main/kotlin/ru/ifmo/se/dating/security/auth/AccessToken.kt new file mode 100644 index 0000000..842bdaa --- /dev/null +++ b/backend/foundation/src/main/kotlin/ru/ifmo/se/dating/security/auth/AccessToken.kt @@ -0,0 +1,6 @@ +package ru.ifmo.se.dating.security.auth + +@JvmInline +value class AccessToken(val text: String) { + data class Payload(val userId: User.Id) +} diff --git a/backend/foundation/src/main/kotlin/ru/ifmo/se/dating/security/auth/Jwt.kt b/backend/foundation/src/main/kotlin/ru/ifmo/se/dating/security/auth/Jwt.kt new file mode 100644 index 0000000..50dc2f9 --- /dev/null +++ b/backend/foundation/src/main/kotlin/ru/ifmo/se/dating/security/auth/Jwt.kt @@ -0,0 +1,16 @@ +package ru.ifmo.se.dating.security.auth + +import io.jsonwebtoken.Claims + +object Jwt { + private const val USER_ID = "user_id" + + fun serialize(payload: AccessToken.Payload): Map = mapOf( + USER_ID to payload.userId.number, + ) + + fun deserialize(claims: Claims): AccessToken.Payload { + val userId = User.Id(claims[USER_ID]!! as Int) + return AccessToken.Payload(userId) + } +} diff --git a/backend/foundation/src/main/kotlin/ru/ifmo/se/dating/security/auth/JwtTokenDecoder.kt b/backend/foundation/src/main/kotlin/ru/ifmo/se/dating/security/auth/JwtTokenDecoder.kt new file mode 100644 index 0000000..5e6db88 --- /dev/null +++ b/backend/foundation/src/main/kotlin/ru/ifmo/se/dating/security/auth/JwtTokenDecoder.kt @@ -0,0 +1,29 @@ +package ru.ifmo.se.dating.security.auth + +import io.jsonwebtoken.ExpiredJwtException +import io.jsonwebtoken.Jwts +import io.jsonwebtoken.MalformedJwtException +import ru.ifmo.se.dating.exception.AuthenticationException +import java.security.PublicKey +import java.time.Clock +import java.util.* + +class JwtTokenDecoder( + private val clock: Clock, + private val publicSignKey: PublicKey, +) : TokenDecoder { + override fun decode(token: AccessToken): AccessToken.Payload = + try { + Jwts.parser() + .verifyWith(publicSignKey) + .clock { Date.from(clock.instant()) } + .build() + .parseSignedClaims(token.text) + .payload + .let { Jwt.deserialize(it) } + } catch (e: ExpiredJwtException) { + throw AuthenticationException(e.message!!, e) + } catch (e: MalformedJwtException) { + throw AuthenticationException(e.message!!, e) + } +} diff --git a/backend/foundation/src/main/kotlin/ru/ifmo/se/dating/security/auth/TokenDecoder.kt b/backend/foundation/src/main/kotlin/ru/ifmo/se/dating/security/auth/TokenDecoder.kt new file mode 100644 index 0000000..e79e0f1 --- /dev/null +++ b/backend/foundation/src/main/kotlin/ru/ifmo/se/dating/security/auth/TokenDecoder.kt @@ -0,0 +1,5 @@ +package ru.ifmo.se.dating.security.auth + +interface TokenDecoder { + fun decode(token: AccessToken): AccessToken.Payload +} diff --git a/backend/foundation/src/main/kotlin/ru/ifmo/se/dating/security/auth/User.kt b/backend/foundation/src/main/kotlin/ru/ifmo/se/dating/security/auth/User.kt new file mode 100644 index 0000000..c8ea785 --- /dev/null +++ b/backend/foundation/src/main/kotlin/ru/ifmo/se/dating/security/auth/User.kt @@ -0,0 +1,12 @@ +package ru.ifmo.se.dating.security.auth + +import ru.ifmo.se.dating.validation.expectId + +object User { + @JvmInline + value class Id(val number: Int) { + init { + expectId(number) + } + } +} diff --git a/backend/foundation/src/main/kotlin/ru/ifmo/se/dating/security/key/Keys.kt b/backend/foundation/src/main/kotlin/ru/ifmo/se/dating/security/key/Keys.kt new file mode 100644 index 0000000..0b675d7 --- /dev/null +++ b/backend/foundation/src/main/kotlin/ru/ifmo/se/dating/security/key/Keys.kt @@ -0,0 +1,39 @@ +package ru.ifmo.se.dating.security.key + +import java.security.Key +import java.security.KeyFactory +import java.security.PrivateKey +import java.security.PublicKey +import java.security.spec.PKCS8EncodedKeySpec +import java.security.spec.X509EncodedKeySpec +import java.util.* +import javax.crypto.SecretKey +import javax.crypto.spec.SecretKeySpec + +object Keys { + fun serialize(key: Key): String = + "${key.algorithm}:${Base64.getEncoder().encodeToString(key.encoded)}" + + fun deserializeSecret(string: String): SecretKey { + val (algorithm, encodedKey) = split(string) + return SecretKeySpec(encodedKey, algorithm) + } + + fun deserializePublic(string: String): PublicKey { + val (algorithm, encodedKey) = split(string) + return KeyFactory.getInstance(algorithm).generatePublic(X509EncodedKeySpec(encodedKey)) + } + + fun deserializePrivate(string: String): PrivateKey { + val (algorithm, encodedKey) = split(string) + return KeyFactory.getInstance(algorithm).generatePrivate(PKCS8EncodedKeySpec(encodedKey)) + } + + private fun split(string: String): Pair { + val parts = string.split(":") + require(parts.size == 2) + val algorithm = parts[0] + val encodedKey = parts[1] + return algorithm to Base64.getDecoder().decode(encodedKey) + } +} diff --git a/backend/foundation/src/main/kotlin/ru/ifmo/se/dating/spring/SpringClockConfiguration.kt b/backend/foundation/src/main/kotlin/ru/ifmo/se/dating/spring/SpringClockConfiguration.kt new file mode 100644 index 0000000..30c86b4 --- /dev/null +++ b/backend/foundation/src/main/kotlin/ru/ifmo/se/dating/spring/SpringClockConfiguration.kt @@ -0,0 +1,11 @@ +package ru.ifmo.se.dating.spring + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import java.time.Clock + +@Configuration +class SpringClockConfiguration { + @Bean + fun clock(): Clock = Clock.systemDefaultZone() +} diff --git a/backend/foundation/src/test/kotlin/ru/ifmo/se/dating/security/key/KeysTest.kt b/backend/foundation/src/test/kotlin/ru/ifmo/se/dating/security/key/KeysTest.kt new file mode 100644 index 0000000..2839c8a --- /dev/null +++ b/backend/foundation/src/test/kotlin/ru/ifmo/se/dating/security/key/KeysTest.kt @@ -0,0 +1,36 @@ +package ru.ifmo.se.dating.security.key + +import org.junit.Assert.assertEquals +import org.junit.Test +import java.security.KeyPairGenerator +import javax.crypto.KeyGenerator + +class KeysTest { + @Test + fun secretRoundTrip() { + repeat(32) { + val key = KeyGenerator.getInstance("AES").generateKey() + assertEquals(key, Keys.serialize(key).let { Keys.deserializeSecret(it) }) + } + } + + @Test + fun publicRoundTrip() { + repeat(8) { + val pair = KeyPairGenerator.getInstance("RSA").genKeyPair() + val (public, private) = pair.public to pair.private + assertEquals(public, Keys.deserializePublic(Keys.serialize(public))) + assertEquals(private, Keys.deserializePrivate(Keys.serialize(private))) + } + } + + @Test + fun generateKeys() { + val aes = KeyGenerator.getInstance("AES").generateKey() + val rsa = KeyPairGenerator.getInstance("RSA").genKeyPair() + + println("AES Secret: '${Keys.serialize(aes)}'") + println("RSA Public: '${Keys.serialize(rsa.public)}'") + println("RSA Private: '${Keys.serialize(rsa.private)}'") + } +} diff --git a/backend/gradle/libs.versions.toml b/backend/gradle/libs.versions.toml index b2ce9c8..4bd429c 100644 --- a/backend/gradle/libs.versions.toml +++ b/backend/gradle/libs.versions.toml @@ -6,6 +6,8 @@ io-swagger-core-v3-swagger = "2.2.25" org-openapitools-jackson-databind-nullable = "0.2.6" org-springdoc-springdoc-openapi-starter = "2.6.0" +io-jsonwebtoken = "0.12.6" + org-jooq = "3.19.15" org-liquibase-liquibase-core = "4.29.2" org-postgresql-postgresql = "42.7.4" @@ -13,14 +15,15 @@ org-postgresql-r2dbc-postgresql = "1.0.7.RELEASE" com-fasterxml-jackson-core-jackson-databind = "2.18.1" jakarta-validation-jakarta-validation-api = "3.1.0" +commons-codec-commons-codec = "1.17.1" org-jetbrains-kotlinx-kotlinx-coroutines = "1.9.0" io-projectreactor-kotlin-reactor-kotlin-extensions = "1.2.3" + io-projectreactor-reactor-test = "3.6.11" junit-junit = "4.13.2" org-testcontainers = "1.20.3" - [libraries] org-springframework-boot-spring-boot = { module = "org.springframework.boot:spring-boot", version.ref = "org-springframework-boot-spring-boot" } org-springframework-boot-spring-boot-starter-webflux = { module = "org.springframework.boot:spring-boot-starter-webflux", version.ref = "org-springframework-boot-spring-boot" } @@ -36,8 +39,13 @@ io-swagger-core-v3-swagger-annotations = { module = "io.swagger.core.v3:swagger- io-swagger-core-v3-swagger-models = { module = "io.swagger.core.v3:swagger-models", version.ref = "io-swagger-core-v3-swagger" } org-openapitools-jackson-databind-nullable = { module = "org.openapitools:jackson-databind-nullable", version.ref = "org-openapitools-jackson-databind-nullable" } +io-jsonwebtoken-jjwt-api = { module = "io.jsonwebtoken:jjwt-api", version.ref = "io-jsonwebtoken" } +io-jsonwebtoken-jjwt-impl = { module = "io.jsonwebtoken:jjwt-impl", version.ref = "io-jsonwebtoken" } +io-jsonwebtoken-jjwt-jackson = { module = "io.jsonwebtoken:jjwt-jackson", version.ref = "io-jsonwebtoken" } + jakarta-validation-jakarta-validation-api = { module = "jakarta.validation:jakarta.validation-api", version.ref = "jakarta-validation-jakarta-validation-api" } com-fasterxml-jackson-core-jackson-databind = { module = "com.fasterxml.jackson.core:jackson-databind", version.ref = "com-fasterxml-jackson-core-jackson-databind" } +commons-codec-commons-codec = { module = "commons-codec:commons-codec", version.ref = "commons-codec-commons-codec" } org-jetbrains-kotlinx-kotlinx-coroutines-reactor = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-reactor", version.ref = "org-jetbrains-kotlinx-kotlinx-coroutines" } io-projectreactor-kotlin-reactor-kotlin-extensions = { module = "io.projectreactor.kotlin:reactor-kotlin-extensions", version.ref = "io-projectreactor-kotlin-reactor-kotlin-extensions" } diff --git a/backend/matchmaker/src/test/kotlin/ru/ifmo/se/dating/matchmaker/MatchmakerTestSuite.kt b/backend/matchmaker/src/test/kotlin/ru/ifmo/se/dating/matchmaker/MatchmakerTestSuite.kt index 81a06b7..15d017a 100644 --- a/backend/matchmaker/src/test/kotlin/ru/ifmo/se/dating/matchmaker/MatchmakerTestSuite.kt +++ b/backend/matchmaker/src/test/kotlin/ru/ifmo/se/dating/matchmaker/MatchmakerTestSuite.kt @@ -5,6 +5,7 @@ import org.springframework.boot.test.context.SpringBootTest import org.springframework.test.context.ActiveProfiles import org.springframework.test.context.ContextConfiguration import org.springframework.test.context.junit4.SpringRunner +import ru.ifmo.se.dating.Application @RunWith(SpringRunner::class) @ActiveProfiles(profiles = ["test"]) diff --git a/backend/people/src/test/kotlin/ru/ifmo/se/dating/people/PeopleTestSuite.kt b/backend/people/src/test/kotlin/ru/ifmo/se/dating/people/PeopleTestSuite.kt index 237ada4..7d592e3 100644 --- a/backend/people/src/test/kotlin/ru/ifmo/se/dating/people/PeopleTestSuite.kt +++ b/backend/people/src/test/kotlin/ru/ifmo/se/dating/people/PeopleTestSuite.kt @@ -5,6 +5,7 @@ import org.springframework.boot.test.context.SpringBootTest import org.springframework.test.context.ActiveProfiles import org.springframework.test.context.ContextConfiguration import org.springframework.test.context.junit4.SpringRunner +import ru.ifmo.se.dating.Application @RunWith(SpringRunner::class) @ActiveProfiles(profiles = ["test"]) diff --git a/backend/settings.gradle.kts b/backend/settings.gradle.kts index 9d977d5..214f94e 100644 --- a/backend/settings.gradle.kts +++ b/backend/settings.gradle.kts @@ -7,6 +7,7 @@ rootProject.name = "itmo-dating-backend" include( ":foundation", ":foundation-test", + ":authik", ":matchmaker", ":people", ) diff --git a/compose.yml b/compose.yml index 003e789..8d398d4 100644 --- a/compose.yml +++ b/compose.yml @@ -1,4 +1,32 @@ services: + authik: + image: eclipse-temurin:21-jdk-alpine + volumes: + - ./backend/authik/build/libs/authik-1.0.0.jar:/authik.jar + command: java -jar /authik.jar + environment: + POSTGRES_DB: ${ITMO_DATING_AUTHIK_POSTGRES_DB?:err} + POSTGRES_USER: ${ITMO_DATING_AUTHIK_POSTGRES_USER?:err} + POSTGRES_PASSWORD: ${ITMO_DATING_AUTHIK_POSTGRES_PASSWORD?:err} + TOKEN_SIGN_KEY_PUBLIC: ${ITMO_DATING_TOKEN_SIGN_KEY_PUBLIC?:err} + TOKEN_SIGN_KEY_PRIVATE: ${ITMO_DATING_AUTHIK_TOKEN_SIGN_KEY_PRIVATE?:err} + TELEGRAM_BOT_TOKEN: ${ITMO_DATING_AUTHIK_TELEGRAM_BOT_TOKEN?:err} + ports: + - 8082:8080 + depends_on: + authik-database: + condition: service_healthy + authik-database: + image: postgres + environment: + POSTGRES_DB: ${ITMO_DATING_AUTHIK_POSTGRES_DB?:err} + POSTGRES_USER: ${ITMO_DATING_AUTHIK_POSTGRES_USER?:err} + POSTGRES_PASSWORD: ${ITMO_DATING_AUTHIK_POSTGRES_PASSWORD?:err} + healthcheck: + test: [ "CMD-SHELL", "pg_isready -U postgres" ] + interval: 1s + timeout: 1s + retries: 16 matchmaker: image: eclipse-temurin:21-jdk-alpine volumes: