(vision) SCS/DDD
This commit is contained in:
@@ -39,6 +39,8 @@ kotlin {
|
||||
implementation(libs.ktor.server.callLogging)
|
||||
implementation(libs.ktor.server.statusPages)
|
||||
implementation(libs.ktor.server.serializationKotlinxJson)
|
||||
implementation(libs.ktor.server.openapi)
|
||||
implementation(libs.ktor.server.swagger)
|
||||
implementation(libs.logback)
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ import at.mocode.gateway.config.configureDatabase
|
||||
import at.mocode.gateway.config.configureSerialization
|
||||
import at.mocode.gateway.config.configureMonitoring
|
||||
import at.mocode.gateway.config.configureSecurity
|
||||
import at.mocode.gateway.config.configureOpenApi
|
||||
import at.mocode.gateway.config.configureSwagger
|
||||
import at.mocode.gateway.routing.configureRouting
|
||||
import io.ktor.server.application.*
|
||||
import io.ktor.server.engine.*
|
||||
@@ -37,6 +39,10 @@ fun Application.module() {
|
||||
configureMonitoring()
|
||||
configureSecurity()
|
||||
|
||||
// Configure API documentation
|
||||
configureOpenApi()
|
||||
configureSwagger()
|
||||
|
||||
// Configure routing - aggregates all bounded context routes
|
||||
configureRouting()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
package at.mocode.gateway.config
|
||||
|
||||
import io.ktor.server.application.*
|
||||
import io.ktor.server.auth.*
|
||||
import io.ktor.server.auth.jwt.*
|
||||
import io.ktor.server.response.*
|
||||
import io.ktor.http.*
|
||||
import io.ktor.server.routing.*
|
||||
import io.ktor.util.pipeline.*
|
||||
import at.mocode.enums.RolleE
|
||||
import at.mocode.enums.BerechtigungE
|
||||
|
||||
/**
|
||||
* Authorization configuration and middleware for role-based access control.
|
||||
*
|
||||
* Provides utilities for checking user roles and permissions on protected endpoints.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Enum representing user roles in the system.
|
||||
*/
|
||||
enum class UserRole {
|
||||
ADMIN,
|
||||
VEREINS_ADMIN,
|
||||
FUNKTIONAER,
|
||||
REITER,
|
||||
TRAINER,
|
||||
RICHTER,
|
||||
TIERARZT,
|
||||
ZUSCHAUER,
|
||||
GAST
|
||||
}
|
||||
|
||||
/**
|
||||
* Enum representing permissions in the system.
|
||||
*/
|
||||
enum class Permission {
|
||||
// Person management
|
||||
PERSON_READ,
|
||||
PERSON_CREATE,
|
||||
PERSON_UPDATE,
|
||||
PERSON_DELETE,
|
||||
|
||||
// Club management
|
||||
VEREIN_READ,
|
||||
VEREIN_CREATE,
|
||||
VEREIN_UPDATE,
|
||||
VEREIN_DELETE,
|
||||
|
||||
// Event management
|
||||
VERANSTALTUNG_READ,
|
||||
VERANSTALTUNG_CREATE,
|
||||
VERANSTALTUNG_UPDATE,
|
||||
VERANSTALTUNG_DELETE,
|
||||
|
||||
// Horse management
|
||||
PFERD_READ,
|
||||
PFERD_CREATE,
|
||||
PFERD_UPDATE,
|
||||
PFERD_DELETE,
|
||||
|
||||
// Master data management
|
||||
STAMMDATEN_READ,
|
||||
STAMMDATEN_UPDATE,
|
||||
|
||||
// System administration
|
||||
SYSTEM_ADMIN,
|
||||
BENUTZER_VERWALTEN,
|
||||
ROLLEN_VERWALTEN
|
||||
}
|
||||
|
||||
/**
|
||||
* Data class representing user authorization context.
|
||||
*/
|
||||
data class UserAuthContext(
|
||||
val userId: String,
|
||||
val username: String,
|
||||
val roles: List<UserRole>,
|
||||
val permissions: List<Permission>
|
||||
)
|
||||
|
||||
/**
|
||||
* Maps domain role enum to authorization role enum.
|
||||
*/
|
||||
private fun mapDomainRoleToUserRole(domainRole: RolleE): UserRole {
|
||||
return when (domainRole) {
|
||||
RolleE.ADMIN -> UserRole.ADMIN
|
||||
RolleE.VEREINS_ADMIN -> UserRole.VEREINS_ADMIN
|
||||
RolleE.FUNKTIONAER -> UserRole.FUNKTIONAER
|
||||
RolleE.REITER -> UserRole.REITER
|
||||
RolleE.TRAINER -> UserRole.TRAINER
|
||||
RolleE.RICHTER -> UserRole.RICHTER
|
||||
RolleE.TIERARZT -> UserRole.TIERARZT
|
||||
RolleE.ZUSCHAUER -> UserRole.ZUSCHAUER
|
||||
RolleE.GAST -> UserRole.GAST
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps domain permission enum to authorization permission enum.
|
||||
*/
|
||||
private fun mapDomainPermissionToPermission(domainPermission: BerechtigungE): Permission {
|
||||
return when (domainPermission) {
|
||||
BerechtigungE.PERSON_READ -> Permission.PERSON_READ
|
||||
BerechtigungE.PERSON_CREATE -> Permission.PERSON_CREATE
|
||||
BerechtigungE.PERSON_UPDATE -> Permission.PERSON_UPDATE
|
||||
BerechtigungE.PERSON_DELETE -> Permission.PERSON_DELETE
|
||||
BerechtigungE.VEREIN_READ -> Permission.VEREIN_READ
|
||||
BerechtigungE.VEREIN_CREATE -> Permission.VEREIN_CREATE
|
||||
BerechtigungE.VEREIN_UPDATE -> Permission.VEREIN_UPDATE
|
||||
BerechtigungE.VEREIN_DELETE -> Permission.VEREIN_DELETE
|
||||
BerechtigungE.VERANSTALTUNG_READ -> Permission.VERANSTALTUNG_READ
|
||||
BerechtigungE.VERANSTALTUNG_CREATE -> Permission.VERANSTALTUNG_CREATE
|
||||
BerechtigungE.VERANSTALTUNG_UPDATE -> Permission.VERANSTALTUNG_UPDATE
|
||||
BerechtigungE.VERANSTALTUNG_DELETE -> Permission.VERANSTALTUNG_DELETE
|
||||
BerechtigungE.PFERD_READ -> Permission.PFERD_READ
|
||||
BerechtigungE.PFERD_CREATE -> Permission.PFERD_CREATE
|
||||
BerechtigungE.PFERD_UPDATE -> Permission.PFERD_UPDATE
|
||||
BerechtigungE.PFERD_DELETE -> Permission.PFERD_DELETE
|
||||
BerechtigungE.STAMMDATEN_READ -> Permission.STAMMDATEN_READ
|
||||
BerechtigungE.STAMMDATEN_UPDATE -> Permission.STAMMDATEN_UPDATE
|
||||
BerechtigungE.SYSTEM_ADMIN -> Permission.SYSTEM_ADMIN
|
||||
BerechtigungE.BENUTZER_VERWALTEN -> Permission.BENUTZER_VERWALTEN
|
||||
BerechtigungE.ROLLEN_VERWALTEN -> Permission.ROLLEN_VERWALTEN
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extension function to get user authorization context from JWT principal.
|
||||
*/
|
||||
fun JWTPrincipal.getUserAuthContext(): UserAuthContext? {
|
||||
val userId = getClaim("userId", String::class) ?: return null
|
||||
val username = getClaim("username", String::class) ?: return null
|
||||
|
||||
// Get roles and permissions from JWT token
|
||||
val domainRoles = getClaim("roles", Array<RolleE>::class)?.toList() ?: emptyList()
|
||||
val domainPermissions = getClaim("permissions", Array<BerechtigungE>::class)?.toList() ?: emptyList()
|
||||
|
||||
// Map domain enums to authorization enums
|
||||
val roles = domainRoles.map { mapDomainRoleToUserRole(it) }
|
||||
val permissions = domainPermissions.map { mapDomainPermissionToPermission(it) }
|
||||
|
||||
return UserAuthContext(
|
||||
userId = userId,
|
||||
username = username,
|
||||
roles = roles,
|
||||
permissions = permissions
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps roles to their corresponding permissions.
|
||||
*/
|
||||
private fun getRolePermissions(roles: List<UserRole>): List<Permission> {
|
||||
val permissions = mutableSetOf<Permission>()
|
||||
|
||||
roles.forEach { role ->
|
||||
when (role) {
|
||||
UserRole.ADMIN -> {
|
||||
permissions.addAll(Permission.values())
|
||||
}
|
||||
UserRole.VEREINS_ADMIN -> {
|
||||
permissions.addAll(listOf(
|
||||
Permission.PERSON_READ, Permission.PERSON_CREATE, Permission.PERSON_UPDATE,
|
||||
Permission.VEREIN_READ, Permission.VEREIN_UPDATE,
|
||||
Permission.PFERD_READ, Permission.PFERD_CREATE, Permission.PFERD_UPDATE,
|
||||
Permission.STAMMDATEN_READ
|
||||
))
|
||||
}
|
||||
UserRole.FUNKTIONAER -> {
|
||||
permissions.addAll(listOf(
|
||||
Permission.PERSON_READ,
|
||||
Permission.VEREIN_READ,
|
||||
Permission.VERANSTALTUNG_READ, Permission.VERANSTALTUNG_CREATE, Permission.VERANSTALTUNG_UPDATE,
|
||||
Permission.PFERD_READ,
|
||||
Permission.STAMMDATEN_READ
|
||||
))
|
||||
}
|
||||
UserRole.TRAINER -> {
|
||||
permissions.addAll(listOf(
|
||||
Permission.PERSON_READ,
|
||||
Permission.VEREIN_READ,
|
||||
Permission.VERANSTALTUNG_READ,
|
||||
Permission.PFERD_READ,
|
||||
Permission.STAMMDATEN_READ
|
||||
))
|
||||
}
|
||||
UserRole.REITER -> {
|
||||
permissions.addAll(listOf(
|
||||
Permission.PERSON_READ,
|
||||
Permission.VEREIN_READ,
|
||||
Permission.VERANSTALTUNG_READ,
|
||||
Permission.PFERD_READ,
|
||||
Permission.STAMMDATEN_READ
|
||||
))
|
||||
}
|
||||
UserRole.RICHTER -> {
|
||||
permissions.addAll(listOf(
|
||||
Permission.PERSON_READ,
|
||||
Permission.VEREIN_READ,
|
||||
Permission.VERANSTALTUNG_READ,
|
||||
Permission.PFERD_READ,
|
||||
Permission.STAMMDATEN_READ
|
||||
))
|
||||
}
|
||||
UserRole.TIERARZT -> {
|
||||
permissions.addAll(listOf(
|
||||
Permission.PERSON_READ,
|
||||
Permission.PFERD_READ,
|
||||
Permission.STAMMDATEN_READ
|
||||
))
|
||||
}
|
||||
UserRole.ZUSCHAUER -> {
|
||||
permissions.addAll(listOf(
|
||||
Permission.VERANSTALTUNG_READ,
|
||||
Permission.STAMMDATEN_READ
|
||||
))
|
||||
}
|
||||
UserRole.GAST -> {
|
||||
permissions.addAll(listOf(
|
||||
Permission.STAMMDATEN_READ
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return permissions.toList()
|
||||
}
|
||||
|
||||
/**
|
||||
* Route extension function to require specific roles.
|
||||
*/
|
||||
fun Route.requireRoles(vararg roles: UserRole, build: Route.() -> Unit): Route {
|
||||
val route = createChild(object : RouteSelector() {
|
||||
override fun evaluate(context: RoutingResolveContext, segmentIndex: Int): RouteSelectorEvaluation {
|
||||
return RouteSelectorEvaluation.Constant
|
||||
}
|
||||
|
||||
override fun toString(): String = "requireRoles(${roles.joinToString()})"
|
||||
})
|
||||
|
||||
route.intercept(ApplicationCallPipeline.Call) {
|
||||
val principal = call.principal<JWTPrincipal>()
|
||||
val authContext = principal?.getUserAuthContext()
|
||||
|
||||
if (authContext == null) {
|
||||
call.respond(HttpStatusCode.Unauthorized, "Authentication required")
|
||||
finish()
|
||||
return@intercept
|
||||
}
|
||||
|
||||
val hasRequiredRole = roles.any { requiredRole ->
|
||||
authContext.roles.contains(requiredRole)
|
||||
}
|
||||
|
||||
if (!hasRequiredRole) {
|
||||
call.respond(
|
||||
HttpStatusCode.Forbidden,
|
||||
"Access denied. Required roles: ${roles.joinToString()}"
|
||||
)
|
||||
finish()
|
||||
return@intercept
|
||||
}
|
||||
}
|
||||
|
||||
route.build()
|
||||
return route
|
||||
}
|
||||
|
||||
/**
|
||||
* Route extension function to require specific permissions.
|
||||
*/
|
||||
fun Route.requirePermissions(vararg permissions: Permission, build: Route.() -> Unit): Route {
|
||||
val route = createChild(object : RouteSelector() {
|
||||
override fun evaluate(context: RoutingResolveContext, segmentIndex: Int): RouteSelectorEvaluation {
|
||||
return RouteSelectorEvaluation.Constant
|
||||
}
|
||||
|
||||
override fun toString(): String = "requirePermissions(${permissions.joinToString()})"
|
||||
})
|
||||
|
||||
route.intercept(ApplicationCallPipeline.Call) {
|
||||
val principal = call.principal<JWTPrincipal>()
|
||||
val authContext = principal?.getUserAuthContext()
|
||||
|
||||
if (authContext == null) {
|
||||
call.respond(HttpStatusCode.Unauthorized, "Authentication required")
|
||||
finish()
|
||||
return@intercept
|
||||
}
|
||||
|
||||
val hasAllPermissions = permissions.all { requiredPermission ->
|
||||
authContext.permissions.contains(requiredPermission)
|
||||
}
|
||||
|
||||
if (!hasAllPermissions) {
|
||||
call.respond(
|
||||
HttpStatusCode.Forbidden,
|
||||
"Access denied. Required permissions: ${permissions.joinToString()}"
|
||||
)
|
||||
finish()
|
||||
return@intercept
|
||||
}
|
||||
}
|
||||
|
||||
route.build()
|
||||
return route
|
||||
}
|
||||
|
||||
/**
|
||||
* Pipeline context extension to get current user authorization context.
|
||||
*/
|
||||
val PipelineContext<Unit, ApplicationCall>.userAuthContext: UserAuthContext?
|
||||
get() = call.principal<JWTPrincipal>()?.getUserAuthContext()
|
||||
|
||||
/**
|
||||
* Application call extension to check if user has specific role.
|
||||
*/
|
||||
fun ApplicationCall.hasRole(role: UserRole): Boolean {
|
||||
val authContext = principal<JWTPrincipal>()?.getUserAuthContext()
|
||||
return authContext?.roles?.contains(role) == true
|
||||
}
|
||||
|
||||
/**
|
||||
* Application call extension to check if user has specific permission.
|
||||
*/
|
||||
fun ApplicationCall.hasPermission(permission: Permission): Boolean {
|
||||
val authContext = principal<JWTPrincipal>()?.getUserAuthContext()
|
||||
return authContext?.permissions?.contains(permission) == true
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import io.ktor.server.application.*
|
||||
import org.jetbrains.exposed.sql.Database
|
||||
import org.jetbrains.exposed.sql.SchemaUtils
|
||||
import org.jetbrains.exposed.sql.transactions.transaction
|
||||
import org.slf4j.LoggerFactory
|
||||
|
||||
/**
|
||||
* Database configuration for the API Gateway.
|
||||
@@ -11,6 +12,7 @@ import org.jetbrains.exposed.sql.transactions.transaction
|
||||
* Sets up database connections and schema initialization for all bounded contexts.
|
||||
*/
|
||||
fun Application.configureDatabase() {
|
||||
val log = LoggerFactory.getLogger("DatabaseConfig")
|
||||
val databaseUrl = environment.config.propertyOrNull("database.url")?.getString()
|
||||
?: "jdbc:postgresql://localhost:5432/meldestelle"
|
||||
val databaseUser = environment.config.propertyOrNull("database.user")?.getString()
|
||||
@@ -46,6 +48,11 @@ fun Application.configureDatabase() {
|
||||
at.mocode.horses.infrastructure.repository.HorseTable
|
||||
)
|
||||
|
||||
// Event Management Context tables
|
||||
SchemaUtils.createMissingTablesAndColumns(
|
||||
at.mocode.events.infrastructure.repository.VeranstaltungTable
|
||||
)
|
||||
|
||||
log.info("Database schemas initialized successfully")
|
||||
} catch (e: Exception) {
|
||||
log.error("Failed to initialize database schemas: ${e.message}")
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package at.mocode.gateway.config
|
||||
|
||||
import io.ktor.server.application.*
|
||||
import io.ktor.server.plugins.openapi.*
|
||||
import io.ktor.server.plugins.swagger.*
|
||||
import io.ktor.server.routing.*
|
||||
|
||||
/**
|
||||
* Configuration for OpenAPI/Swagger documentation.
|
||||
*
|
||||
* This module configures the OpenAPI specification generation and Swagger UI
|
||||
* for the API Gateway, providing comprehensive API documentation.
|
||||
*/
|
||||
fun Application.configureOpenApi() {
|
||||
install(OpenAPI) {
|
||||
codegen = org.openapitools.codegen.CodegenType.CLIENT
|
||||
info {
|
||||
title = "Meldestelle Self-Contained Systems API"
|
||||
version = "1.0.0"
|
||||
description = "Unified API Gateway for Austrian Equestrian Federation bounded contexts"
|
||||
contact {
|
||||
name = "API Support"
|
||||
email = "support@mocode.at"
|
||||
}
|
||||
license {
|
||||
name = "MIT"
|
||||
url = "https://opensource.org/licenses/MIT"
|
||||
}
|
||||
}
|
||||
server("http://localhost:8080") {
|
||||
description = "Development server"
|
||||
}
|
||||
server("https://api.meldestelle.at") {
|
||||
description = "Production server"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for Swagger UI.
|
||||
*
|
||||
* Provides an interactive web interface for exploring and testing the API.
|
||||
*/
|
||||
fun Application.configureSwagger() {
|
||||
routing {
|
||||
swaggerUI(path = "swagger", swaggerFile = "openapi/documentation.yaml") {
|
||||
version = "4.15.5"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,16 @@ package at.mocode.gateway.config
|
||||
|
||||
import io.ktor.server.application.*
|
||||
import io.ktor.server.plugins.cors.routing.*
|
||||
import io.ktor.server.auth.*
|
||||
import io.ktor.server.auth.jwt.*
|
||||
import io.ktor.http.*
|
||||
import com.auth0.jwt.JWT
|
||||
import com.auth0.jwt.algorithms.Algorithm
|
||||
|
||||
/**
|
||||
* Security configuration for the API Gateway.
|
||||
*
|
||||
* Configures CORS, authentication, and other security-related settings.
|
||||
* Configures CORS, JWT authentication, and other security-related settings.
|
||||
*/
|
||||
fun Application.configureSecurity() {
|
||||
install(CORS) {
|
||||
@@ -29,10 +33,52 @@ fun Application.configureSecurity() {
|
||||
anyHost() // This should be restricted in production
|
||||
}
|
||||
|
||||
// TODO: Add JWT authentication configuration
|
||||
// install(Authentication) {
|
||||
// jwt("auth-jwt") {
|
||||
// // JWT configuration
|
||||
// }
|
||||
// }
|
||||
// JWT Configuration
|
||||
val jwtConfig = JwtConfig.fromEnvironment()
|
||||
|
||||
install(Authentication) {
|
||||
jwt("auth-jwt") {
|
||||
realm = jwtConfig.realm
|
||||
verifier(
|
||||
JWT
|
||||
.require(Algorithm.HMAC256(jwtConfig.secret))
|
||||
.withAudience(jwtConfig.audience)
|
||||
.withIssuer(jwtConfig.issuer)
|
||||
.build()
|
||||
)
|
||||
validate { credential ->
|
||||
if (credential.payload.getClaim("userId").asString() != null) {
|
||||
JWTPrincipal(credential.payload)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
challenge { defaultScheme, realm ->
|
||||
call.respond(HttpStatusCode.Unauthorized, "Token is not valid or has expired")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* JWT Configuration data class.
|
||||
*/
|
||||
data class JwtConfig(
|
||||
val secret: String,
|
||||
val issuer: String,
|
||||
val audience: String,
|
||||
val realm: String,
|
||||
val expirationTime: Long = 3600000L // 1 hour in milliseconds
|
||||
) {
|
||||
companion object {
|
||||
fun fromEnvironment(): JwtConfig {
|
||||
return JwtConfig(
|
||||
secret = System.getenv("JWT_SECRET") ?: "default-secret-key-change-in-production",
|
||||
issuer = System.getenv("JWT_ISSUER") ?: "meldestelle-api",
|
||||
audience = System.getenv("JWT_AUDIENCE") ?: "meldestelle-users",
|
||||
realm = System.getenv("JWT_REALM") ?: "Meldestelle API",
|
||||
expirationTime = System.getenv("JWT_EXPIRATION")?.toLongOrNull() ?: 3600000L
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
package at.mocode.gateway.routing
|
||||
|
||||
import io.ktor.server.application.*
|
||||
import io.ktor.server.auth.*
|
||||
import io.ktor.server.auth.jwt.*
|
||||
import io.ktor.server.request.*
|
||||
import io.ktor.server.response.*
|
||||
import io.ktor.server.routing.*
|
||||
import io.ktor.http.*
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Authentication routes for the API Gateway.
|
||||
*
|
||||
* Provides endpoints for user login, logout, registration, and profile management.
|
||||
* This is a simplified implementation that will be connected to the actual
|
||||
* authentication services once the database layer is implemented.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Data classes for API requests and responses
|
||||
*/
|
||||
@Serializable
|
||||
data class LoginRequest(
|
||||
val usernameOrEmail: String,
|
||||
val password: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class LoginResponse(
|
||||
val success: Boolean,
|
||||
val token: String? = null,
|
||||
val message: String? = null,
|
||||
val user: UserProfileResponse? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class RegisterRequest(
|
||||
val personId: String, // UUID as string
|
||||
val username: String,
|
||||
val email: String,
|
||||
val password: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class RegisterResponse(
|
||||
val success: Boolean,
|
||||
val message: String? = null,
|
||||
val user: UserProfileResponse? = null,
|
||||
val errors: List<ValidationErrorResponse>? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ValidationErrorResponse(
|
||||
val field: String,
|
||||
val message: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class UserProfileResponse(
|
||||
val userId: String,
|
||||
val username: String,
|
||||
val email: String,
|
||||
val isActive: Boolean,
|
||||
val isEmailVerified: Boolean,
|
||||
val lastLogin: String? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ChangePasswordRequest(
|
||||
val currentPassword: String,
|
||||
val newPassword: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ChangePasswordResponse(
|
||||
val success: Boolean,
|
||||
val message: String? = null,
|
||||
val errors: List<ValidationErrorResponse>? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* Configures authentication routes
|
||||
*/
|
||||
fun Route.authRoutes(
|
||||
authenticationService: at.mocode.members.domain.service.AuthenticationService,
|
||||
jwtService: at.mocode.members.domain.service.JwtService
|
||||
) {
|
||||
route("/auth") {
|
||||
|
||||
// Login endpoint
|
||||
post("/login") {
|
||||
try {
|
||||
val loginRequest = call.receive<LoginRequest>()
|
||||
|
||||
// Validate input
|
||||
if (loginRequest.usernameOrEmail.isEmpty() || loginRequest.password.isEmpty()) {
|
||||
call.respond(
|
||||
HttpStatusCode.BadRequest,
|
||||
LoginResponse(
|
||||
success = false,
|
||||
message = "Username/email and password are required"
|
||||
)
|
||||
)
|
||||
return@post
|
||||
}
|
||||
|
||||
// Authenticate user
|
||||
val authResult = authenticationService.authenticate(
|
||||
loginRequest.usernameOrEmail,
|
||||
loginRequest.password
|
||||
)
|
||||
|
||||
if (authResult.isSuccess) {
|
||||
val user = authResult.user!!
|
||||
val tokenInfo = authResult.tokenInfo!!
|
||||
|
||||
call.respond(
|
||||
HttpStatusCode.OK,
|
||||
LoginResponse(
|
||||
success = true,
|
||||
token = tokenInfo.token,
|
||||
message = "Login successful",
|
||||
user = UserProfileResponse(
|
||||
userId = user.userId.toString(),
|
||||
username = user.username,
|
||||
email = user.email,
|
||||
isActive = user.istAktiv,
|
||||
isEmailVerified = user.istEmailVerifiziert,
|
||||
lastLogin = user.letzteAnmeldung?.toString()
|
||||
)
|
||||
)
|
||||
)
|
||||
} else {
|
||||
call.respond(
|
||||
HttpStatusCode.Unauthorized,
|
||||
LoginResponse(
|
||||
success = false,
|
||||
message = authResult.errorMessage ?: "Invalid credentials"
|
||||
)
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
call.respond(
|
||||
HttpStatusCode.BadRequest,
|
||||
LoginResponse(
|
||||
success = false,
|
||||
message = "Invalid request: ${e.message}"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Register endpoint
|
||||
post("/register") {
|
||||
try {
|
||||
val registerRequest = call.receive<RegisterRequest>()
|
||||
|
||||
// TODO: Implement actual registration logic
|
||||
// For now, return a mock response
|
||||
if (registerRequest.username.isNotEmpty() &&
|
||||
registerRequest.email.isNotEmpty() &&
|
||||
registerRequest.password.length >= 8) {
|
||||
|
||||
call.respond(
|
||||
HttpStatusCode.Created,
|
||||
RegisterResponse(
|
||||
success = true,
|
||||
message = "User registered successfully",
|
||||
user = UserProfileResponse(
|
||||
userId = "mock-user-id-${System.currentTimeMillis()}",
|
||||
username = registerRequest.username,
|
||||
email = registerRequest.email,
|
||||
isActive = true,
|
||||
isEmailVerified = false,
|
||||
lastLogin = null
|
||||
)
|
||||
)
|
||||
)
|
||||
} else {
|
||||
val errors = mutableListOf<ValidationErrorResponse>()
|
||||
if (registerRequest.username.isEmpty()) {
|
||||
errors.add(ValidationErrorResponse("username", "Username is required"))
|
||||
}
|
||||
if (registerRequest.email.isEmpty()) {
|
||||
errors.add(ValidationErrorResponse("email", "Email is required"))
|
||||
}
|
||||
if (registerRequest.password.length < 8) {
|
||||
errors.add(ValidationErrorResponse("password", "Password must be at least 8 characters"))
|
||||
}
|
||||
|
||||
call.respond(
|
||||
HttpStatusCode.BadRequest,
|
||||
RegisterResponse(
|
||||
success = false,
|
||||
message = "Registration failed",
|
||||
errors = errors
|
||||
)
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
call.respond(
|
||||
HttpStatusCode.BadRequest,
|
||||
RegisterResponse(
|
||||
success = false,
|
||||
message = "Invalid request: ${e.message}"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Protected routes (require authentication)
|
||||
authenticate("auth-jwt") {
|
||||
|
||||
// Get user profile
|
||||
get("/profile") {
|
||||
try {
|
||||
val principal = call.principal<JWTPrincipal>()
|
||||
val userId = principal?.getClaim("userId", String::class)
|
||||
|
||||
if (userId != null) {
|
||||
// TODO: Fetch actual user data from database
|
||||
call.respond(
|
||||
HttpStatusCode.OK,
|
||||
UserProfileResponse(
|
||||
userId = userId,
|
||||
username = "mock_user",
|
||||
email = "mock@example.com",
|
||||
isActive = true,
|
||||
isEmailVerified = true,
|
||||
lastLogin = null
|
||||
)
|
||||
)
|
||||
} else {
|
||||
call.respond(HttpStatusCode.Unauthorized, "Invalid token")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
call.respond(HttpStatusCode.InternalServerError, "Error retrieving profile: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
// Change password
|
||||
post("/change-password") {
|
||||
try {
|
||||
val principal = call.principal<JWTPrincipal>()
|
||||
val userId = principal?.getClaim("userId", String::class)
|
||||
|
||||
if (userId != null) {
|
||||
val changePasswordRequest = call.receive<ChangePasswordRequest>()
|
||||
|
||||
// TODO: Implement actual password change logic
|
||||
if (changePasswordRequest.newPassword.length >= 8) {
|
||||
call.respond(
|
||||
HttpStatusCode.OK,
|
||||
ChangePasswordResponse(
|
||||
success = true,
|
||||
message = "Password changed successfully"
|
||||
)
|
||||
)
|
||||
} else {
|
||||
call.respond(
|
||||
HttpStatusCode.BadRequest,
|
||||
ChangePasswordResponse(
|
||||
success = false,
|
||||
message = "Password change failed",
|
||||
errors = listOf(
|
||||
ValidationErrorResponse("newPassword", "Password must be at least 8 characters")
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
call.respond(HttpStatusCode.Unauthorized, "Invalid token")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
call.respond(
|
||||
HttpStatusCode.BadRequest,
|
||||
ChangePasswordResponse(
|
||||
success = false,
|
||||
message = "Invalid request: ${e.message}"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh token
|
||||
post("/refresh") {
|
||||
try {
|
||||
val token = call.request.header("Authorization")?.removePrefix("Bearer ")
|
||||
if (token != null) {
|
||||
// TODO: Implement actual token refresh logic
|
||||
call.respond(
|
||||
HttpStatusCode.OK,
|
||||
mapOf(
|
||||
"token" to "refreshed_mock_jwt_token_${System.currentTimeMillis()}",
|
||||
"message" to "Token refreshed successfully"
|
||||
)
|
||||
)
|
||||
} else {
|
||||
call.respond(HttpStatusCode.BadRequest, "No token provided")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
call.respond(HttpStatusCode.InternalServerError, "Error refreshing token: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
// Logout (client-side token invalidation)
|
||||
post("/logout") {
|
||||
// In a stateless JWT system, logout is typically handled client-side
|
||||
// by removing the token. For server-side logout, you would need a token blacklist.
|
||||
call.respond(
|
||||
HttpStatusCode.OK,
|
||||
mapOf("message" to "Logged out successfully. Please remove the token from client storage.")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,11 @@ import at.mocode.masterdata.application.usecase.CreateCountryUseCase
|
||||
import at.mocode.masterdata.application.usecase.GetCountryUseCase
|
||||
import at.mocode.masterdata.infrastructure.api.CountryController
|
||||
import at.mocode.masterdata.infrastructure.repository.LandRepositoryImpl
|
||||
import at.mocode.members.domain.service.AuthenticationService
|
||||
import at.mocode.members.domain.service.JwtService
|
||||
import at.mocode.members.domain.service.UserAuthorizationService
|
||||
import at.mocode.members.domain.service.PasswordService
|
||||
import at.mocode.members.infrastructure.repository.*
|
||||
import io.ktor.http.*
|
||||
import io.ktor.server.application.*
|
||||
import io.ktor.server.response.*
|
||||
@@ -25,6 +30,29 @@ fun Application.configureRouting() {
|
||||
val landRepository = LandRepositoryImpl()
|
||||
val horseRepository = HorseRepositoryImpl()
|
||||
|
||||
// Initialize authentication repositories
|
||||
val userRepository = UserRepositoryImpl()
|
||||
val personRolleRepository = PersonRolleRepositoryImpl()
|
||||
val rolleRepository = RolleRepositoryImpl()
|
||||
val rolleBerechtigungRepository = RolleBerechtigungRepositoryImpl()
|
||||
val berechtigungRepository = BerechtigungRepositoryImpl()
|
||||
|
||||
// Initialize authentication services
|
||||
val passwordService = PasswordService()
|
||||
val userAuthorizationService = UserAuthorizationService(
|
||||
userRepository,
|
||||
personRolleRepository,
|
||||
rolleRepository,
|
||||
rolleBerechtigungRepository,
|
||||
berechtigungRepository
|
||||
)
|
||||
val jwtService = JwtService(userAuthorizationService)
|
||||
val authenticationService = AuthenticationService(
|
||||
userRepository,
|
||||
passwordService,
|
||||
jwtService
|
||||
)
|
||||
|
||||
// Initialize use cases
|
||||
val getCountryUseCase = GetCountryUseCase(landRepository)
|
||||
val createCountryUseCase = CreateCountryUseCase(landRepository)
|
||||
@@ -43,10 +71,12 @@ fun Application.configureRouting() {
|
||||
version = "1.0.0",
|
||||
description = "Self-Contained Systems API Gateway for Austrian Equestrian Federation",
|
||||
availableContexts = listOf(
|
||||
"authentication",
|
||||
"master-data",
|
||||
"horse-registry"
|
||||
),
|
||||
endpoints = mapOf(
|
||||
"authentication" to "/auth/*",
|
||||
"master-data" to "/api/masterdata/*",
|
||||
"horse-registry" to "/api/horses/*"
|
||||
)
|
||||
@@ -60,6 +90,7 @@ fun Application.configureRouting() {
|
||||
HealthStatus(
|
||||
status = "UP",
|
||||
contexts = mapOf(
|
||||
"authentication" to "UP",
|
||||
"master-data" to "UP",
|
||||
"horse-registry" to "UP"
|
||||
)
|
||||
@@ -74,6 +105,11 @@ fun Application.configureRouting() {
|
||||
title = "Meldestelle Self-Contained Systems API",
|
||||
description = "Unified API Gateway for all bounded contexts",
|
||||
contexts = listOf(
|
||||
ContextInfo(
|
||||
name = "Authentication Context",
|
||||
path = "/auth",
|
||||
description = "User authentication, registration, and profile management"
|
||||
),
|
||||
ContextInfo(
|
||||
name = "Master Data Context",
|
||||
path = "/api/masterdata",
|
||||
@@ -91,6 +127,9 @@ fun Application.configureRouting() {
|
||||
|
||||
// Configure routes for each bounded context
|
||||
|
||||
// Authentication Routes
|
||||
authRoutes(authenticationService, jwtService)
|
||||
|
||||
// Master Data Context Routes
|
||||
countryController.configureRoutes(this)
|
||||
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
package at.mocode.gateway
|
||||
|
||||
import at.mocode.dto.base.BaseDto
|
||||
import at.mocode.gateway.routing.ApiGatewayInfo
|
||||
import at.mocode.gateway.routing.HealthStatus
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.client.statement.*
|
||||
import io.ktor.http.*
|
||||
import io.ktor.server.testing.*
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlin.test.*
|
||||
|
||||
/**
|
||||
* Integration tests for the API Gateway.
|
||||
*
|
||||
* These tests verify that all API endpoints are working correctly
|
||||
* and that the OpenAPI/Swagger integration is functioning properly.
|
||||
*/
|
||||
class ApiIntegrationTest {
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
@Test
|
||||
fun testApiGatewayInfo() = testApplication {
|
||||
application {
|
||||
module()
|
||||
}
|
||||
|
||||
client.get("/").apply {
|
||||
assertEquals(HttpStatusCode.OK, status)
|
||||
val responseText = bodyAsText()
|
||||
assertTrue(responseText.contains("Meldestelle API Gateway"))
|
||||
|
||||
// Parse response as BaseDto
|
||||
val response = json.decodeFromString<BaseDto<ApiGatewayInfo>>(responseText)
|
||||
assertTrue(response.success)
|
||||
assertNotNull(response.data)
|
||||
assertEquals("Meldestelle API Gateway", response.data!!.name)
|
||||
assertEquals("1.0.0", response.data!!.version)
|
||||
assertTrue(response.data!!.availableContexts.contains("authentication"))
|
||||
assertTrue(response.data!!.availableContexts.contains("master-data"))
|
||||
assertTrue(response.data!!.availableContexts.contains("horse-registry"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testHealthCheck() = testApplication {
|
||||
application {
|
||||
module()
|
||||
}
|
||||
|
||||
client.get("/health").apply {
|
||||
assertEquals(HttpStatusCode.OK, status)
|
||||
val responseText = bodyAsText()
|
||||
|
||||
// Parse response as BaseDto
|
||||
val response = json.decodeFromString<BaseDto<HealthStatus>>(responseText)
|
||||
assertTrue(response.success)
|
||||
assertNotNull(response.data)
|
||||
assertEquals("UP", response.data!!.status)
|
||||
assertTrue(response.data!!.contexts.containsKey("authentication"))
|
||||
assertTrue(response.data!!.contexts.containsKey("master-data"))
|
||||
assertTrue(response.data!!.contexts.containsKey("horse-registry"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testApiDocumentation() = testApplication {
|
||||
application {
|
||||
module()
|
||||
}
|
||||
|
||||
client.get("/api").apply {
|
||||
assertEquals(HttpStatusCode.OK, status)
|
||||
val responseText = bodyAsText()
|
||||
assertTrue(responseText.contains("Meldestelle Self-Contained Systems API"))
|
||||
assertTrue(responseText.contains("Authentication Context"))
|
||||
assertTrue(responseText.contains("Master Data Context"))
|
||||
assertTrue(responseText.contains("Horse Registry Context"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSwaggerUI() = testApplication {
|
||||
application {
|
||||
module()
|
||||
}
|
||||
|
||||
client.get("/swagger").apply {
|
||||
// Swagger UI should be accessible (might return HTML or redirect)
|
||||
assertTrue(status.isSuccess() || status == HttpStatusCode.Found)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testNotFoundEndpoint() = testApplication {
|
||||
application {
|
||||
module()
|
||||
}
|
||||
|
||||
client.get("/nonexistent").apply {
|
||||
assertEquals(HttpStatusCode.NotFound, status)
|
||||
val responseText = bodyAsText()
|
||||
assertTrue(responseText.contains("Endpoint not found"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testCorsHeaders() = testApplication {
|
||||
application {
|
||||
module()
|
||||
}
|
||||
|
||||
client.options("/") {
|
||||
header(HttpHeaders.Origin, "http://localhost:3000")
|
||||
header(HttpHeaders.AccessControlRequestMethod, "GET")
|
||||
}.apply {
|
||||
// CORS should be configured
|
||||
assertTrue(status.isSuccess())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testContentNegotiation() = testApplication {
|
||||
application {
|
||||
module()
|
||||
}
|
||||
|
||||
client.get("/") {
|
||||
header(HttpHeaders.Accept, "application/json")
|
||||
}.apply {
|
||||
assertEquals(HttpStatusCode.OK, status)
|
||||
assertEquals(ContentType.Application.Json.withCharset(Charsets.UTF_8), contentType())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testMasterDataEndpoints() = testApplication {
|
||||
application {
|
||||
module()
|
||||
}
|
||||
|
||||
// Test countries endpoint
|
||||
client.get("/api/masterdata/countries").apply {
|
||||
assertEquals(HttpStatusCode.OK, status)
|
||||
val responseText = bodyAsText()
|
||||
assertTrue(responseText.contains("success"))
|
||||
}
|
||||
|
||||
// Test active countries endpoint
|
||||
client.get("/api/masterdata/countries/active").apply {
|
||||
assertEquals(HttpStatusCode.OK, status)
|
||||
val responseText = bodyAsText()
|
||||
assertTrue(responseText.contains("success"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testHorseRegistryEndpoints() = testApplication {
|
||||
application {
|
||||
module()
|
||||
}
|
||||
|
||||
// Test horses endpoint (should require authentication)
|
||||
client.get("/api/horses").apply {
|
||||
// Should return unauthorized or redirect to login
|
||||
assertTrue(status == HttpStatusCode.Unauthorized || status == HttpStatusCode.Found)
|
||||
}
|
||||
|
||||
// Test horse stats endpoint
|
||||
client.get("/api/horses/stats").apply {
|
||||
// Should require authentication
|
||||
assertTrue(status == HttpStatusCode.Unauthorized || status == HttpStatusCode.Found)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testAuthenticationEndpoints() = testApplication {
|
||||
application {
|
||||
module()
|
||||
}
|
||||
|
||||
// Test registration endpoint structure
|
||||
client.post("/auth/register") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody("""
|
||||
{
|
||||
"email": "test@example.com",
|
||||
"password": "TestPassword123!",
|
||||
"firstName": "Test",
|
||||
"lastName": "User",
|
||||
"phoneNumber": "+43123456789"
|
||||
}
|
||||
""".trimIndent())
|
||||
}.apply {
|
||||
// Should process the request (might fail due to validation or database issues)
|
||||
assertTrue(status.value in 200..499)
|
||||
}
|
||||
|
||||
// Test login endpoint structure
|
||||
client.post("/auth/login") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody("""
|
||||
{
|
||||
"email": "test@example.com",
|
||||
"password": "TestPassword123!"
|
||||
}
|
||||
""".trimIndent())
|
||||
}.apply {
|
||||
// Should process the request
|
||||
assertTrue(status.value in 200..499)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testApiResponseFormat() = testApplication {
|
||||
application {
|
||||
module()
|
||||
}
|
||||
|
||||
client.get("/").apply {
|
||||
assertEquals(HttpStatusCode.OK, status)
|
||||
val responseText = bodyAsText()
|
||||
|
||||
// Verify BaseDto structure
|
||||
assertTrue(responseText.contains("\"success\""))
|
||||
assertTrue(responseText.contains("\"data\""))
|
||||
assertTrue(responseText.contains("\"message\""))
|
||||
|
||||
// Should be valid JSON
|
||||
assertNotNull(json.decodeFromString<BaseDto<ApiGatewayInfo>>(responseText))
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user