diff --git a/gateway-service/pom.xml b/gateway-service/pom.xml
index 9487884..a22e1b1 100644
--- a/gateway-service/pom.xml
+++ b/gateway-service/pom.xml
@@ -43,6 +43,50 @@
spring-cloud-starter-gateway
+
+
+ org.springframework.boot
+ spring-boot-starter-security
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-data-r2dbc
+
+
+
+
+ org.postgresql
+ r2dbc-postgresql
+ runtime
+
+
+
+
+ io.jsonwebtoken
+ jjwt-api
+ 0.13.0
+
+
+ io.jsonwebtoken
+ jjwt-impl
+ 0.13.0
+ runtime
+
+
+ io.jsonwebtoken
+ jjwt-jackson
+ 0.13.0
+ runtime
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-validation
+
+
org.springframework.cloud
diff --git a/gateway-service/src/main/java/com/posthub/gateway/config/AuthProperties.java b/gateway-service/src/main/java/com/posthub/gateway/config/AuthProperties.java
new file mode 100644
index 0000000..573c92b
--- /dev/null
+++ b/gateway-service/src/main/java/com/posthub/gateway/config/AuthProperties.java
@@ -0,0 +1,19 @@
+package com.posthub.gateway.config;
+
+import lombok.Getter;
+import lombok.Setter;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+import java.util.ArrayList;
+import java.util.List;
+
+@Component
+@ConfigurationProperties(prefix = "auth")
+@Getter
+@Setter
+public class AuthProperties {
+
+ private List publicPaths = new ArrayList<>();
+ private List adminPaths = new ArrayList<>();
+}
\ No newline at end of file
diff --git a/gateway-service/src/main/java/com/posthub/gateway/config/R2dbcConfig.java b/gateway-service/src/main/java/com/posthub/gateway/config/R2dbcConfig.java
new file mode 100644
index 0000000..c2f069e
--- /dev/null
+++ b/gateway-service/src/main/java/com/posthub/gateway/config/R2dbcConfig.java
@@ -0,0 +1,63 @@
+package com.posthub.gateway.config;
+
+import com.posthub.gateway.model.enums.RegistrationStatus;
+import com.posthub.gateway.model.enums.UserRole;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.core.convert.converter.Converter;
+import org.springframework.data.convert.ReadingConverter;
+import org.springframework.data.convert.WritingConverter;
+import org.springframework.data.r2dbc.convert.R2dbcCustomConversions;
+import org.springframework.data.r2dbc.dialect.PostgresDialect;
+import org.springframework.lang.NonNull;
+
+import java.util.List;
+
+@Configuration
+public class R2dbcConfig {
+
+ @Bean
+ public R2dbcCustomConversions r2dbcCustomConversions() {
+ return R2dbcCustomConversions.of(
+ PostgresDialect.INSTANCE.getStoreConversions(),
+ List.of(
+ new RegistrationStatusReadConverter(),
+ new RegistrationStatusWriteConverter(),
+ new UserRoleReadConverter(),
+ new UserRoleWriteConverter()
+ )
+ );
+ }
+
+ @ReadingConverter
+ static class RegistrationStatusReadConverter implements Converter {
+ @Override
+ public RegistrationStatus convert(@NonNull String source) {
+ return RegistrationStatus.valueOf(source);
+ }
+ }
+
+ @WritingConverter
+ static class RegistrationStatusWriteConverter implements Converter {
+ @Override
+ public String convert(@NonNull RegistrationStatus source) {
+ return source.name();
+ }
+ }
+
+ @ReadingConverter
+ static class UserRoleReadConverter implements Converter {
+ @Override
+ public UserRole convert(@NonNull String source) {
+ return UserRole.valueOf(source);
+ }
+ }
+
+ @WritingConverter
+ static class UserRoleWriteConverter implements Converter {
+ @Override
+ public String convert(@NonNull UserRole source) {
+ return source.name();
+ }
+ }
+}
\ No newline at end of file
diff --git a/gateway-service/src/main/java/com/posthub/gateway/config/SecurityConfig.java b/gateway-service/src/main/java/com/posthub/gateway/config/SecurityConfig.java
new file mode 100644
index 0000000..1856014
--- /dev/null
+++ b/gateway-service/src/main/java/com/posthub/gateway/config/SecurityConfig.java
@@ -0,0 +1,56 @@
+package com.posthub.gateway.config;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.HttpStatus;
+import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity;
+import org.springframework.security.config.web.server.ServerHttpSecurity;
+import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.security.web.server.SecurityWebFilterChain;
+import reactor.core.publisher.Mono;
+
+@Configuration
+@EnableWebFluxSecurity
+public class SecurityConfig {
+
+ @Bean
+ public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) {
+ return http
+ .csrf(ServerHttpSecurity.CsrfSpec::disable)
+ .httpBasic(ServerHttpSecurity.HttpBasicSpec::disable)
+ .formLogin(ServerHttpSecurity.FormLoginSpec::disable)
+ .logout(ServerHttpSecurity.LogoutSpec::disable)
+ .authorizeExchange(exchanges -> exchanges
+ .pathMatchers(HttpMethod.OPTIONS, "/**").permitAll()
+ .pathMatchers(
+ "/api/auth/login",
+ "/api/auth/register",
+ "/api/auth/refresh/token"
+ ).permitAll()
+ .pathMatchers(
+ "/actuator/**",
+ "/v3/api-docs/**",
+ "/swagger-ui/**"
+ ).permitAll()
+ .anyExchange().permitAll()
+ )
+ .exceptionHandling(exceptions -> exceptions
+ .authenticationEntryPoint((exchange, ex) -> {
+ exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
+ return Mono.empty();
+ })
+ .accessDeniedHandler((exchange, denied) -> {
+ exchange.getResponse().setStatusCode(HttpStatus.FORBIDDEN);
+ return Mono.empty();
+ })
+ )
+ .build();
+ }
+
+ @Bean
+ public PasswordEncoder passwordEncoder() {
+ return new BCryptPasswordEncoder();
+ }
+}
\ No newline at end of file
diff --git a/gateway-service/src/main/java/com/posthub/gateway/controller/AuthController.java b/gateway-service/src/main/java/com/posthub/gateway/controller/AuthController.java
new file mode 100644
index 0000000..51382c8
--- /dev/null
+++ b/gateway-service/src/main/java/com/posthub/gateway/controller/AuthController.java
@@ -0,0 +1,40 @@
+package com.posthub.gateway.controller;
+
+import com.posthub.gateway.model.request.LoginUserRequest;
+import com.posthub.gateway.model.request.RegistrationUserRequest;
+import com.posthub.gateway.model.response.JwtResponse;
+import com.posthub.gateway.service.AuthService;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+import reactor.core.publisher.Mono;
+
+@RestController
+@RequestMapping("/api/auth")
+@RequiredArgsConstructor
+public class AuthController {
+
+ private final AuthService authService;
+
+ @PostMapping("/register")
+ public Mono> register(@Valid @RequestBody RegistrationUserRequest request) {
+ return authService.register(request)
+ .map(response -> ResponseEntity.status(HttpStatus.CREATED).body(response));
+ }
+
+ @PostMapping("/login")
+ public Mono> login(@Valid @RequestBody LoginUserRequest request) {
+ return authService.login(request)
+ .map(ResponseEntity::ok);
+ }
+
+ @GetMapping("/refresh/token")
+ public Mono> refreshToken(
+ @RequestHeader(HttpHeaders.AUTHORIZATION) String authHeader) {
+ return authService.refreshToken(authHeader)
+ .map(ResponseEntity::ok);
+ }
+}
\ No newline at end of file
diff --git a/gateway-service/src/main/java/com/posthub/gateway/filter/AuthFilter.java b/gateway-service/src/main/java/com/posthub/gateway/filter/AuthFilter.java
deleted file mode 100644
index c315ebc..0000000
--- a/gateway-service/src/main/java/com/posthub/gateway/filter/AuthFilter.java
+++ /dev/null
@@ -1,63 +0,0 @@
-package com.posthub.gateway.filter;
-
-import lombok.extern.slf4j.Slf4j;
-import org.springframework.cloud.gateway.filter.GatewayFilter;
-import org.springframework.cloud.gateway.filter.factory.AbstractGatewayFilterFactory;
-import org.springframework.http.HttpHeaders;
-import org.springframework.http.HttpStatus;
-import org.springframework.http.server.reactive.ServerHttpRequest;
-import org.springframework.http.server.reactive.ServerHttpResponse;
-import org.springframework.stereotype.Component;
-import reactor.core.publisher.Mono;
-
-import java.util.List;
-
-/**
- * Per-route filter: validates that Authorization header contains a Bearer token.
- * Does NOT verify JWT signature — downstream services handle that.
- *
- * Usage in application.yml:
- * filters:
- * - AuthFilter
- */
-@Slf4j
-@Component
-public class AuthFilter extends AbstractGatewayFilterFactory {
-
- public AuthFilter() {
- super(Config.class);
- }
-
- @Override
- public GatewayFilter apply(Config config) {
- return (exchange, chain) -> {
- ServerHttpRequest request = exchange.getRequest();
- String path = request.getURI().getPath();
-
- List authHeaders = request.getHeaders().getOrEmpty(HttpHeaders.AUTHORIZATION);
-
- if (authHeaders.isEmpty()) {
- log.warn("Missing Authorization header for {}", path);
- return unauthorized(exchange);
- }
-
- String token = authHeaders.getFirst();
- if (token == null || !token.startsWith("Bearer ") || !token.contains(".")) {
- log.warn("Invalid Authorization header format for {}", path);
- return unauthorized(exchange);
- }
-
- log.debug("Token present for {}", path);
- return chain.filter(exchange);
- };
- }
-
- private Mono unauthorized(org.springframework.web.server.ServerWebExchange exchange) {
- ServerHttpResponse response = exchange.getResponse();
- response.setStatusCode(HttpStatus.UNAUTHORIZED);
- return response.setComplete();
- }
-
- public static class Config {
- }
-}
diff --git a/gateway-service/src/main/java/com/posthub/gateway/filter/JwtAuthGlobalFilter.java b/gateway-service/src/main/java/com/posthub/gateway/filter/JwtAuthGlobalFilter.java
new file mode 100644
index 0000000..a5f50d0
--- /dev/null
+++ b/gateway-service/src/main/java/com/posthub/gateway/filter/JwtAuthGlobalFilter.java
@@ -0,0 +1,116 @@
+package com.posthub.gateway.filter;
+
+import com.posthub.gateway.config.AuthProperties;
+import com.posthub.gateway.security.JwtTokenProvider;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.cloud.gateway.filter.GatewayFilterChain;
+import org.springframework.cloud.gateway.filter.GlobalFilter;
+import org.springframework.core.Ordered;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.server.reactive.ServerHttpRequest;
+import org.springframework.http.server.reactive.ServerHttpResponse;
+import org.springframework.stereotype.Component;
+import org.springframework.util.AntPathMatcher;
+import org.springframework.web.server.ServerWebExchange;
+import reactor.core.publisher.Mono;
+
+import java.util.List;
+
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class JwtAuthGlobalFilter implements GlobalFilter, Ordered {
+
+ private final JwtTokenProvider jwtTokenProvider;
+ private final AuthProperties authProperties;
+ private final AntPathMatcher pathMatcher = new AntPathMatcher();
+
+ @Override
+ public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) {
+ ServerHttpRequest request = exchange.getRequest();
+ String path = request.getURI().getPath();
+
+ // 1. Public paths — пропустить без JWT
+ if (isPublicPath(path)) {
+ return chain.filter(exchange);
+ }
+
+ // 2. Извлечь токен
+ String token = extractToken(request);
+ if (token == null) {
+ log.warn("Missing JWT for path: {}", path);
+ return unauthorized(exchange);
+ }
+
+ // 3. Валидация JWT
+ if (!jwtTokenProvider.validateToken(token)) {
+ log.warn("Invalid JWT for path: {}", path);
+ return unauthorized(exchange);
+ }
+
+ // 4. Проверка роли для admin-paths
+ String role = jwtTokenProvider.getUserRole(token);
+ if (isAdminPath(path) && !"ADMIN".equals(role)) {
+ log.warn("Access denied for role {} to admin path: {}", role, path);
+ return forbidden(exchange);
+ }
+
+ // 5. Добавить headers в downstream-запрос
+ String userId = jwtTokenProvider.getUserId(token);
+ String email = jwtTokenProvider.getUserEmail(token);
+ String username = jwtTokenProvider.getUsername(token);
+
+ ServerHttpRequest modifiedRequest = request.mutate()
+ .header("X-User-Id", userId)
+ .header("X-User-Email", email)
+ .header("X-User-Name", username)
+ .header("X-User-Role", role)
+ .build();
+
+ log.debug("JWT validated for user {} (role={}) -> {}", email, role, path);
+
+ return chain.filter(exchange.mutate().request(modifiedRequest).build());
+ }
+
+ private String extractToken(ServerHttpRequest request) {
+ List authHeaders = request.getHeaders().getOrEmpty(HttpHeaders.AUTHORIZATION);
+ if (authHeaders.isEmpty()) {
+ return null;
+ }
+ String header = authHeaders.getFirst();
+ if (header != null && header.startsWith("Bearer ") && header.contains(".")) {
+ return header.substring(7);
+ }
+ return null;
+ }
+
+ private boolean isPublicPath(String path) {
+ return authProperties.getPublicPaths().stream()
+ .anyMatch(pattern -> pathMatcher.match(pattern, path));
+ }
+
+ private boolean isAdminPath(String path) {
+ return authProperties.getAdminPaths().stream()
+ .anyMatch(pattern -> pathMatcher.match(pattern, path));
+ }
+
+ private Mono unauthorized(ServerWebExchange exchange) {
+ ServerHttpResponse response = exchange.getResponse();
+ response.setStatusCode(HttpStatus.UNAUTHORIZED);
+ return response.setComplete();
+ }
+
+ private Mono forbidden(ServerWebExchange exchange) {
+ ServerHttpResponse response = exchange.getResponse();
+ response.setStatusCode(HttpStatus.FORBIDDEN);
+ return response.setComplete();
+ }
+
+ @Override
+ public int getOrder() {
+ // После RequestLoggingFilter (HIGHEST_PRECEDENCE), но до route filters
+ return Ordered.HIGHEST_PRECEDENCE + 1;
+ }
+}
\ No newline at end of file
diff --git a/gateway-service/src/main/java/com/posthub/gateway/model/entity/User.java b/gateway-service/src/main/java/com/posthub/gateway/model/entity/User.java
new file mode 100644
index 0000000..0b99db9
--- /dev/null
+++ b/gateway-service/src/main/java/com/posthub/gateway/model/entity/User.java
@@ -0,0 +1,43 @@
+package com.posthub.gateway.model.entity;
+
+import com.posthub.gateway.model.enums.RegistrationStatus;
+import com.posthub.gateway.model.enums.UserRole;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+import org.springframework.data.annotation.Id;
+import org.springframework.data.relational.core.mapping.Column;
+import org.springframework.data.relational.core.mapping.Table;
+
+import java.time.LocalDateTime;
+
+@Table("users")
+@Getter
+@Setter
+@NoArgsConstructor
+public class User {
+
+ @Id
+ private Integer id;
+
+ private String username;
+
+ private String password;
+
+ private String email;
+
+ private LocalDateTime created;
+
+ private LocalDateTime updated;
+
+ @Column("last_login")
+ private LocalDateTime lastLogin;
+
+ private Boolean deleted;
+
+ @Column("registration_status")
+ private RegistrationStatus registrationStatus;
+
+ @Column("role")
+ private UserRole role;
+}
\ No newline at end of file
diff --git a/gateway-service/src/main/java/com/posthub/gateway/model/enums/RegistrationStatus.java b/gateway-service/src/main/java/com/posthub/gateway/model/enums/RegistrationStatus.java
new file mode 100644
index 0000000..3dd2fec
--- /dev/null
+++ b/gateway-service/src/main/java/com/posthub/gateway/model/enums/RegistrationStatus.java
@@ -0,0 +1,7 @@
+package com.posthub.gateway.model.enums;
+
+public enum RegistrationStatus {
+ ACTIVE,
+ BLOCKED,
+ DELETED
+}
\ No newline at end of file
diff --git a/gateway-service/src/main/java/com/posthub/gateway/model/enums/UserRole.java b/gateway-service/src/main/java/com/posthub/gateway/model/enums/UserRole.java
new file mode 100644
index 0000000..88104e9
--- /dev/null
+++ b/gateway-service/src/main/java/com/posthub/gateway/model/enums/UserRole.java
@@ -0,0 +1,6 @@
+package com.posthub.gateway.model.enums;
+
+public enum UserRole {
+ USER,
+ ADMIN
+}
\ No newline at end of file
diff --git a/gateway-service/src/main/java/com/posthub/gateway/model/request/LoginUserRequest.java b/gateway-service/src/main/java/com/posthub/gateway/model/request/LoginUserRequest.java
new file mode 100644
index 0000000..10730eb
--- /dev/null
+++ b/gateway-service/src/main/java/com/posthub/gateway/model/request/LoginUserRequest.java
@@ -0,0 +1,18 @@
+package com.posthub.gateway.model.request;
+
+import jakarta.validation.constraints.Email;
+import jakarta.validation.constraints.NotBlank;
+import lombok.Getter;
+import lombok.Setter;
+
+@Getter
+@Setter
+public class LoginUserRequest {
+
+ @NotBlank
+ @Email
+ private String email;
+
+ @NotBlank
+ private String password;
+}
\ No newline at end of file
diff --git a/gateway-service/src/main/java/com/posthub/gateway/model/request/RegistrationUserRequest.java b/gateway-service/src/main/java/com/posthub/gateway/model/request/RegistrationUserRequest.java
new file mode 100644
index 0000000..d138928
--- /dev/null
+++ b/gateway-service/src/main/java/com/posthub/gateway/model/request/RegistrationUserRequest.java
@@ -0,0 +1,28 @@
+package com.posthub.gateway.model.request;
+
+import jakarta.validation.constraints.Email;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Size;
+import lombok.Getter;
+import lombok.Setter;
+
+@Getter
+@Setter
+public class RegistrationUserRequest {
+
+ @NotBlank
+ @Size(min = 2, max = 30)
+ private String username;
+
+ @NotBlank
+ @Email
+ @Size(max = 50)
+ private String email;
+
+ @NotBlank
+ @Size(min = 6, max = 80)
+ private String password;
+
+ @NotBlank
+ private String confirmPassword;
+}
\ No newline at end of file
diff --git a/gateway-service/src/main/java/com/posthub/gateway/model/response/JwtResponse.java b/gateway-service/src/main/java/com/posthub/gateway/model/response/JwtResponse.java
new file mode 100644
index 0000000..c71e031
--- /dev/null
+++ b/gateway-service/src/main/java/com/posthub/gateway/model/response/JwtResponse.java
@@ -0,0 +1,14 @@
+package com.posthub.gateway.model.response;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+@Getter
+@AllArgsConstructor
+public class JwtResponse {
+
+ private String token;
+ private String refreshToken;
+ private String email;
+ private String username;
+}
\ No newline at end of file
diff --git a/gateway-service/src/main/java/com/posthub/gateway/repository/UserRepository.java b/gateway-service/src/main/java/com/posthub/gateway/repository/UserRepository.java
new file mode 100644
index 0000000..871eda4
--- /dev/null
+++ b/gateway-service/src/main/java/com/posthub/gateway/repository/UserRepository.java
@@ -0,0 +1,14 @@
+package com.posthub.gateway.repository;
+
+import com.posthub.gateway.model.entity.User;
+import org.springframework.data.repository.reactive.ReactiveCrudRepository;
+import reactor.core.publisher.Mono;
+
+public interface UserRepository extends ReactiveCrudRepository {
+
+ Mono findByEmail(String email);
+
+ Mono findByUsername(String username);
+
+ Mono existsByEmail(String email);
+}
\ No newline at end of file
diff --git a/gateway-service/src/main/java/com/posthub/gateway/security/JwtTokenProvider.java b/gateway-service/src/main/java/com/posthub/gateway/security/JwtTokenProvider.java
new file mode 100644
index 0000000..8c71a21
--- /dev/null
+++ b/gateway-service/src/main/java/com/posthub/gateway/security/JwtTokenProvider.java
@@ -0,0 +1,111 @@
+package com.posthub.gateway.security;
+
+import com.posthub.gateway.model.entity.User;
+import io.jsonwebtoken.*;
+import io.jsonwebtoken.io.Decoders;
+import io.jsonwebtoken.security.Keys;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.lang.NonNull;
+import org.springframework.stereotype.Component;
+
+import javax.crypto.SecretKey;
+import java.time.LocalDateTime;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.Map;
+
+@Slf4j
+@Component
+public class JwtTokenProvider {
+
+ private static final String USER_ID = "userId";
+ private static final String USERNAME = "username";
+ private static final String USER_EMAIL = "email";
+ private static final String USER_ROLE = "role";
+ private static final String USER_REGISTRATION_STATUS = "registrationStatus";
+ private static final String SESSION_ID = "sessionId";
+ private static final String LAST_UPDATE = "lastUpdate";
+
+ private final SecretKey secretKey;
+ private final long jwtValidityInMilliseconds;
+
+ public JwtTokenProvider(@Value("${jwt.secret}") String secret,
+ @Value("${jwt.expiration:103600000}") long jwtValidityInMilliseconds) {
+ byte[] decoded = Decoders.BASE64.decode(secret);
+ this.secretKey = Keys.hmacShaKeyFor(decoded);
+ this.jwtValidityInMilliseconds = jwtValidityInMilliseconds;
+ }
+
+ public String generateToken(@NonNull User user, String sessionId) {
+ Map claims = new HashMap<>();
+ claims.put(USER_ID, user.getId());
+ claims.put(USERNAME, user.getUsername());
+ claims.put(USER_EMAIL, user.getEmail());
+ claims.put(USER_ROLE, user.getRole().name());
+ claims.put(USER_REGISTRATION_STATUS, user.getRegistrationStatus().name());
+ claims.put(SESSION_ID, sessionId);
+ claims.put(LAST_UPDATE, LocalDateTime.now().toString());
+ return createToken(claims, user.getEmail());
+ }
+
+ public String refreshToken(String token) {
+ Claims claims = getAllClaimsFromToken(token);
+ return createToken(claims, claims.getSubject());
+ }
+
+ public boolean validateToken(String token) {
+ try {
+ Jws claims = Jwts.parser()
+ .verifyWith(secretKey)
+ .build()
+ .parseSignedClaims(token);
+ return !claims.getPayload().getExpiration().before(new Date());
+ } catch (JwtException | IllegalArgumentException e) {
+ log.warn("Invalid JWT token: {}", e.getMessage());
+ return false;
+ }
+ }
+
+ public String getUsername(String token) {
+ return getAllClaimsFromToken(token).get(USERNAME, String.class);
+ }
+
+ public String getUserId(String token) {
+ return String.valueOf(getAllClaimsFromToken(token).get(USER_ID));
+ }
+
+ public String getUserEmail(String token) {
+ return getAllClaimsFromToken(token).get(USER_EMAIL, String.class);
+ }
+
+ public String getUserRole(String token) {
+ return getAllClaimsFromToken(token).get(USER_ROLE, String.class);
+ }
+
+ public String getSessionId(String token) {
+ return getAllClaimsFromToken(token).get(SESSION_ID, String.class);
+ }
+
+ private Claims getAllClaimsFromToken(String token) {
+ try {
+ return Jwts.parser()
+ .verifyWith(secretKey)
+ .build()
+ .parseSignedClaims(token)
+ .getPayload();
+ } catch (ExpiredJwtException e) {
+ return e.getClaims();
+ }
+ }
+
+ private String createToken(Map claims, String subject) {
+ return Jwts.builder()
+ .claims(claims)
+ .subject(subject)
+ .issuedAt(new Date())
+ .expiration(new Date(System.currentTimeMillis() + jwtValidityInMilliseconds))
+ .signWith(secretKey)
+ .compact();
+ }
+}
\ No newline at end of file
diff --git a/gateway-service/src/main/java/com/posthub/gateway/service/AuthService.java b/gateway-service/src/main/java/com/posthub/gateway/service/AuthService.java
new file mode 100644
index 0000000..bb9fc91
--- /dev/null
+++ b/gateway-service/src/main/java/com/posthub/gateway/service/AuthService.java
@@ -0,0 +1,108 @@
+package com.posthub.gateway.service;
+
+import com.posthub.gateway.model.entity.User;
+import com.posthub.gateway.model.enums.RegistrationStatus;
+import com.posthub.gateway.model.enums.UserRole;
+import com.posthub.gateway.model.request.LoginUserRequest;
+import com.posthub.gateway.model.request.RegistrationUserRequest;
+import com.posthub.gateway.model.response.JwtResponse;
+import com.posthub.gateway.repository.UserRepository;
+import com.posthub.gateway.security.JwtTokenProvider;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.HttpStatus;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.stereotype.Service;
+import org.springframework.web.server.ResponseStatusException;
+import reactor.core.publisher.Mono;
+
+import java.time.LocalDateTime;
+import java.util.UUID;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class AuthService {
+
+ private final UserRepository userRepository;
+ private final JwtTokenProvider jwtTokenProvider;
+ private final PasswordEncoder passwordEncoder;
+
+ public Mono register(RegistrationUserRequest request) {
+ if (!request.getPassword().equals(request.getConfirmPassword())) {
+ return Mono.error(new ResponseStatusException(HttpStatus.BAD_REQUEST, "Passwords do not match"));
+ }
+
+ return userRepository.existsByEmail(request.getEmail())
+ .flatMap(exists -> {
+ if (exists) {
+ return Mono.error(new ResponseStatusException(HttpStatus.CONFLICT, "Email already registered"));
+ }
+
+ User user = new User();
+ user.setUsername(request.getUsername());
+ user.setEmail(request.getEmail());
+ user.setPassword(passwordEncoder.encode(request.getPassword()));
+ user.setRegistrationStatus(RegistrationStatus.ACTIVE);
+ user.setRole(UserRole.USER);
+ user.setCreated(LocalDateTime.now());
+ user.setUpdated(LocalDateTime.now());
+ user.setDeleted(false);
+
+ return userRepository.save(user);
+ })
+ .map(savedUser -> {
+ String sessionId = UUID.randomUUID().toString();
+ String token = jwtTokenProvider.generateToken(savedUser, sessionId);
+ String refreshToken = jwtTokenProvider.refreshToken(token);
+ log.info("User registered: {}", savedUser.getEmail());
+ return new JwtResponse(token, refreshToken, savedUser.getEmail(), savedUser.getUsername());
+ });
+ }
+
+ public Mono login(LoginUserRequest request) {
+ return userRepository.findByEmail(request.getEmail())
+ .switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid credentials")))
+ .flatMap(user -> {
+ if (!passwordEncoder.matches(request.getPassword(), user.getPassword())) {
+ return Mono.error(new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid credentials"));
+ }
+ if (user.getRegistrationStatus() != RegistrationStatus.ACTIVE) {
+ return Mono.error(new ResponseStatusException(HttpStatus.FORBIDDEN, "Account is not active"));
+ }
+
+ user.setLastLogin(LocalDateTime.now());
+ user.setUpdated(LocalDateTime.now());
+
+ return userRepository.save(user);
+ })
+ .map(user -> {
+ String sessionId = UUID.randomUUID().toString();
+ String token = jwtTokenProvider.generateToken(user, sessionId);
+ String refreshToken = jwtTokenProvider.refreshToken(token);
+ log.info("User logged in: {}", user.getEmail());
+ return new JwtResponse(token, refreshToken, user.getEmail(), user.getUsername());
+ });
+ }
+
+ public Mono refreshToken(String authHeader) {
+ if (authHeader == null || !authHeader.startsWith("Bearer ")) {
+ return Mono.error(new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Missing or invalid Authorization header"));
+ }
+
+ String token = authHeader.substring(7);
+ if (!jwtTokenProvider.validateToken(token)) {
+ return Mono.error(new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid or expired token"));
+ }
+
+ String email = jwtTokenProvider.getUserEmail(token);
+ return userRepository.findByEmail(email)
+ .switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.UNAUTHORIZED, "User not found")))
+ .map(user -> {
+ String sessionId = jwtTokenProvider.getSessionId(token);
+ String newToken = jwtTokenProvider.generateToken(user, sessionId);
+ String newRefreshToken = jwtTokenProvider.refreshToken(newToken);
+ return new JwtResponse(newToken, newRefreshToken, user.getEmail(), user.getUsername());
+ });
+ }
+}
\ No newline at end of file
diff --git a/gateway-service/src/main/resources/application.yml b/gateway-service/src/main/resources/application.yml
index 75b9c48..0ff4bd1 100644
--- a/gateway-service/src/main/resources/application.yml
+++ b/gateway-service/src/main/resources/application.yml
@@ -6,6 +6,16 @@ spring:
application:
name: gateway-service
+ # ---- R2DBC (reactive DB) ----
+ r2dbc:
+ url: r2dbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:appdb}
+ username: ${DB_USERNAME:app}
+ password: ${DB_PASSWORD:}
+ pool:
+ initial-size: 2
+ max-size: 10
+ max-idle-time: 30m
+
cloud:
consul:
host: ${CONSUL_HOST:localhost}
@@ -18,27 +28,21 @@ spring:
prefer-ip-address: true
instance-id: ${spring.application.name}:${random.value}
- # Spring Cloud Gateway 2025.0 — new prefix: spring.cloud.gateway.server.webflux
gateway:
server:
webflux:
- # Trust Nginx reverse proxy for forwarded headers
trusted-proxies: 127\.0\.0\.1|10\.0\.0\..*|172\.1[6-9]\..*|172\.2[0-9]\..*|172\.3[0-1]\..*|192\.168\..*
-
discovery:
locator:
enabled: true
lower-case-service-id: true
-
httpclient:
connect-timeout: 5000
response-timeout: 60s
-
default-filters:
- DedupeResponseHeader=Access-Control-Allow-Origin Access-Control-Allow-Credentials, RETAIN_FIRST
-
routes:
- # RAG Service - actuator (health, info)
+ # RAG Service - actuator
- id: rag-service-actuator
uri: lb://rag-service
predicates:
@@ -57,19 +61,29 @@ spring:
- RewritePath=/api/rag(?/?.*), ${segment}
- AddRequestHeader=X-Forwarded-Prefix, /api/rag
- # Analytics Service (will be added later)
- # - id: analytics-service-api
- # uri: lb://analytics-service
- # predicates:
- # - Path=/api/analytics/**
- # - Method=GET,POST
- # filters:
- # - RewritePath=/api/analytics(?/?.*), ${segment}
+# ---- JWT ----
+jwt:
+ secret: ${JWT_SECRET:}
+ expiration: ${JWT_EXPIRATION:103600000}
+# ---- Auth path config ----
+auth:
+ public-paths:
+ - /api/auth/login
+ - /api/auth/register
+ - /api/auth/refresh/token
+ - /actuator/**
+ - /api/*/v3/api-docs/**
+ - /api/*/swagger-ui/**
+ admin-paths:
+ - /api/*/admin/**
+
+# ---- CORS ----
gateway:
cors:
allowed-origins: ${CORS_ORIGINS:*}
+# ---- Actuator ----
management:
endpoints:
web:
@@ -81,8 +95,10 @@ management:
gateway:
enabled: true
+# ---- Logging ----
logging:
level:
root: INFO
com.posthub.gateway: DEBUG
org.springframework.cloud.gateway: INFO
+ org.springframework.r2dbc: INFO
\ No newline at end of file