diff --git a/admin-service/Dockerfile b/admin-service/Dockerfile new file mode 100644 index 0000000..ce95a54 --- /dev/null +++ b/admin-service/Dockerfile @@ -0,0 +1,25 @@ +FROM gradle:8.5-jdk17 AS build +WORKDIR /app + +ENV GRADLE_OPTS="-Dhttps.protocols=TLSv1.2,TLSv1.3" + +COPY build.gradle.kts settings.gradle.kts ./ +COPY admin-service/build.gradle.kts ./admin-service/ +COPY common/build.gradle.kts ./common/ + +COPY admin-service/src ./admin-service/src +COPY common/src ./common/src + +RUN gradle :admin-service:clean :admin-service:bootJar --no-daemon --no-build-cache + +FROM eclipse-temurin:17-jre-jammy +WORKDIR /app + +RUN groupadd -r spring && useradd -r -g spring spring +USER spring:spring + +COPY --from=build /app/admin-service/build/libs/admin-service.jar app.jar + +EXPOSE 8086 + +ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/admin-service/build.gradle.kts b/admin-service/build.gradle.kts new file mode 100644 index 0000000..5db8078 --- /dev/null +++ b/admin-service/build.gradle.kts @@ -0,0 +1,57 @@ +plugins { + id("org.springframework.boot") + id("io.spring.dependency-management") + id("org.openapi.generator") +} + +configure { + imports { + mavenBom(org.springframework.boot.gradle.plugin.SpringBootPlugin.BOM_COORDINATES) + } +} + +openApiGenerate { + generatorName.set("spring") + inputSpec.set("${project.projectDir}/src/main/resources/static/openapi.yaml") + outputDir.set(project.layout.buildDirectory.get().asFile.resolve("generated/openapi").absolutePath) + apiPackage.set("ru.itmo.admin.generated.api") + modelPackage.set("ru.itmo.admin.generated.model") + invokerPackage.set("ru.itmo.admin.generated") + configOptions.set( + mapOf( + "library" to "spring-boot", + "useSpringBoot3" to "true", + "useBeanValidation" to "true", + "openApiNullable" to "false", + "useTags" to "true", + "configPackage" to "ru.itmo.admin.generated.config", + "interfaceOnly" to "true" + ) + ) +} + +tasks.named("compileJava") { + dependsOn("openApiGenerate") +} + +sourceSets["main"].java.srcDir(project.layout.buildDirectory.get().asFile.resolve("generated/openapi/src/main/java").absolutePath) + +dependencies { + implementation("org.springframework.boot:spring-boot") + implementation("org.springframework.boot:spring-boot-starter-web") + implementation("org.springframework.boot:spring-boot-starter-security") + implementation("org.springframework.boot:spring-boot-starter-actuator") + implementation("org.springframework.boot:spring-boot-starter-validation") + implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.5.0") + implementation(project(":common")) + + compileOnly("org.projectlombok:lombok") + annotationProcessor("org.projectlombok:lombok") + + testImplementation("org.springframework.boot:spring-boot-starter-test") +} + +tasks.bootJar { + archiveFileName.set("admin-service.jar") + mainClass.set("ru.itmo.admin.AdminServiceApplication") +} diff --git a/admin-service/src/main/java/ru/itmo/admin/AdminServiceApplication.java b/admin-service/src/main/java/ru/itmo/admin/AdminServiceApplication.java new file mode 100644 index 0000000..a71643f --- /dev/null +++ b/admin-service/src/main/java/ru/itmo/admin/AdminServiceApplication.java @@ -0,0 +1,12 @@ +package ru.itmo.admin; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class AdminServiceApplication { + + public static void main(String[] args) { + SpringApplication.run(AdminServiceApplication.class, args); + } +} diff --git a/admin-service/src/main/java/ru/itmo/admin/config/JwtConfig.java b/admin-service/src/main/java/ru/itmo/admin/config/JwtConfig.java new file mode 100644 index 0000000..ddced39 --- /dev/null +++ b/admin-service/src/main/java/ru/itmo/admin/config/JwtConfig.java @@ -0,0 +1,18 @@ +package ru.itmo.admin.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import ru.itmo.common.security.JwtUtil; + +@Configuration +public class JwtConfig { + + @Value("${jwt.secret}") + private String secret; + + @Bean + public JwtUtil jwtUtil() { + return new JwtUtil(secret); + } +} diff --git a/admin-service/src/main/java/ru/itmo/admin/config/OpenApiConfig.java b/admin-service/src/main/java/ru/itmo/admin/config/OpenApiConfig.java new file mode 100644 index 0000000..6226a76 --- /dev/null +++ b/admin-service/src/main/java/ru/itmo/admin/config/OpenApiConfig.java @@ -0,0 +1,27 @@ +package ru.itmo.admin.config; + +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class OpenApiConfig { + + private static final String BEARER_AUTH = "bearerAuth"; + + @Bean + public OpenAPI openAPI() { + return new OpenAPI() + .components(new Components() + .addSecuritySchemes(BEARER_AUTH, + new SecurityScheme() + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT") + .description("Введите JWT access token (получить через POST /api/auth/login)"))) + .addSecurityItem(new SecurityRequirement().addList(BEARER_AUTH)); + } +} diff --git a/admin-service/src/main/java/ru/itmo/admin/config/SecurityConfig.java b/admin-service/src/main/java/ru/itmo/admin/config/SecurityConfig.java new file mode 100644 index 0000000..bb795f7 --- /dev/null +++ b/admin-service/src/main/java/ru/itmo/admin/config/SecurityConfig.java @@ -0,0 +1,34 @@ +package ru.itmo.admin.config; + +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import ru.itmo.admin.security.JwtAuthenticationFilter; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private final JwtAuthenticationFilter jwtAuthenticationFilter; + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + .csrf(csrf -> csrf.disable()) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(auth -> auth + .requestMatchers("/actuator/**", "/health", "/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html").permitAll() + // All admin endpoints require ADMIN role + .requestMatchers("/admin/**").hasRole("ADMIN") + .anyRequest().permitAll() + ) + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + return http.build(); + } +} diff --git a/admin-service/src/main/java/ru/itmo/admin/controller/HealthApiController.java b/admin-service/src/main/java/ru/itmo/admin/controller/HealthApiController.java new file mode 100644 index 0000000..0b38911 --- /dev/null +++ b/admin-service/src/main/java/ru/itmo/admin/controller/HealthApiController.java @@ -0,0 +1,18 @@ +package ru.itmo.admin.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.RestController; +import ru.itmo.admin.generated.api.HealthApi; +import ru.itmo.admin.generated.model.HealthResponse; + +@RestController +@org.springframework.web.bind.annotation.RequestMapping("${openapi.adminService.base-path:/admin}") +public class HealthApiController implements HealthApi { + + @Override + public ResponseEntity healthCheck() { + HealthResponse response = new HealthResponse(); + response.setStatus("UP"); + return ResponseEntity.ok(response); + } +} diff --git a/admin-service/src/main/java/ru/itmo/admin/exception/GlobalExceptionHandler.java b/admin-service/src/main/java/ru/itmo/admin/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..3be5416 --- /dev/null +++ b/admin-service/src/main/java/ru/itmo/admin/exception/GlobalExceptionHandler.java @@ -0,0 +1,22 @@ +package ru.itmo.admin.exception; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.util.List; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity> handleIllegalArgument(IllegalArgumentException ex) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(List.of(ex.getMessage())); + } + + @ExceptionHandler(IllegalStateException.class) + public ResponseEntity> handleIllegalState(IllegalStateException ex) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(List.of(ex.getMessage())); + } +} diff --git a/admin-service/src/main/java/ru/itmo/admin/security/JwtAuthenticationFilter.java b/admin-service/src/main/java/ru/itmo/admin/security/JwtAuthenticationFilter.java new file mode 100644 index 0000000..c84482d --- /dev/null +++ b/admin-service/src/main/java/ru/itmo/admin/security/JwtAuthenticationFilter.java @@ -0,0 +1,64 @@ +package ru.itmo.admin.security; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; +import ru.itmo.common.security.JwtUtil; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +@Component +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtUtil jwtUtil; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + String authHeader = request.getHeader("Authorization"); + + if (authHeader == null || !authHeader.startsWith("Bearer ")) { + filterChain.doFilter(request, response); + return; + } + + String token = authHeader.substring(7); + + try { + if (jwtUtil.validateToken(token, "access")) { + UUID userId = jwtUtil.getUserIdFromToken(token); + Boolean isAdmin = jwtUtil.getIsAdminFromToken(token); + + List authorities = new ArrayList<>(); + authorities.add(new SimpleGrantedAuthority("ROLE_USER")); + if (Boolean.TRUE.equals(isAdmin)) { + authorities.add(new SimpleGrantedAuthority("ROLE_ADMIN")); + } + + UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( + userId, + null, + authorities + ); + authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + } catch (Exception ignored) { + // leave context empty, will result in 403 for protected endpoints + } + + filterChain.doFilter(request, response); + } +} diff --git a/admin-service/src/main/resources/application-docker.yml b/admin-service/src/main/resources/application-docker.yml new file mode 100644 index 0000000..bdff1c7 --- /dev/null +++ b/admin-service/src/main/resources/application-docker.yml @@ -0,0 +1,2 @@ +jwt: + secret: ${JWT_SECRET:your-256-bit-secret-key-must-be-at-least-32-characters-long-for-security} diff --git a/admin-service/src/main/resources/application.yml b/admin-service/src/main/resources/application.yml new file mode 100644 index 0000000..bfbf770 --- /dev/null +++ b/admin-service/src/main/resources/application.yml @@ -0,0 +1,31 @@ +server: + port: 8086 + +spring: + application: + name: admin-service + autoconfigure: + exclude: + - org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration + - org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration + - org.springframework.boot.autoconfigure.liquibase.LiquibaseAutoConfiguration + +management: + endpoints: + web: + exposure: + include: health,info,metrics + endpoint: + health: + show-details: always + +openapi: + adminService: + base-path: /admin + +jwt: + secret: ${JWT_SECRET:your-256-bit-secret-key-must-be-at-least-32-characters-long-for-security} + +logging: + level: + ru.itmo: INFO diff --git a/admin-service/src/main/resources/static/openapi.yaml b/admin-service/src/main/resources/static/openapi.yaml new file mode 100644 index 0000000..ab21fa8 --- /dev/null +++ b/admin-service/src/main/resources/static/openapi.yaml @@ -0,0 +1,38 @@ +openapi: 3.0.0 +info: + title: Admin Service API + description: API сервиса администрирования + version: 0.1.0 +servers: + - url: http://localhost:8086/admin + description: Dev server + +paths: + /health: + get: + summary: Проверка состояния сервиса + description: Возвращает статус работоспособности admin-service + operationId: healthCheck + tags: + - Health + responses: + '200': + description: Сервис работает + content: + application/json: + schema: + $ref: '#/components/schemas/HealthResponse' + +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + schemas: + HealthResponse: + type: object + properties: + status: + type: string + example: "UP" diff --git a/docker-compose.yml b/docker-compose.yml index 7915add..29862bb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -153,6 +153,20 @@ services: networks: - domain-registrar-network + admin-service: + build: + context: . + dockerfile: admin-service/Dockerfile + container_name: admin-service + ports: + - "8086:8086" + env_file: + - .env + environment: + - SPRING_PROFILES_ACTIVE=docker + networks: + - domain-registrar-network + exdns: build: context: ./exdns diff --git a/settings.gradle.kts b/settings.gradle.kts index 7487718..d185999 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -11,4 +11,4 @@ pluginManagement { rootProject.name = "domain-registrar" -include("api-gateway", "common", "domain-service", "notification-service", "auth-service", "order-service") +include("api-gateway", "common", "domain-service", "notification-service", "auth-service", "order-service", "admin-service")