From 1c1965a082fd8421f676e9ef5dd68a78a5db4d78 Mon Sep 17 00:00:00 2001 From: balex Date: Sun, 8 Mar 2026 01:00:58 +0100 Subject: [PATCH] auth gateway --- gateway-service/pom.xml | 44 +++++++ .../gateway/config/AuthProperties.java | 19 +++ .../posthub/gateway/config/R2dbcConfig.java | 63 ++++++++++ .../gateway/config/SecurityConfig.java | 56 +++++++++ .../gateway/controller/AuthController.java | 40 ++++++ .../posthub/gateway/filter/AuthFilter.java | 63 ---------- .../gateway/filter/JwtAuthGlobalFilter.java | 116 ++++++++++++++++++ .../posthub/gateway/model/entity/User.java | 43 +++++++ .../model/enums/RegistrationStatus.java | 7 ++ .../posthub/gateway/model/enums/UserRole.java | 6 + .../model/request/LoginUserRequest.java | 18 +++ .../request/RegistrationUserRequest.java | 28 +++++ .../gateway/model/response/JwtResponse.java | 14 +++ .../gateway/repository/UserRepository.java | 14 +++ .../gateway/security/JwtTokenProvider.java | 111 +++++++++++++++++ .../posthub/gateway/service/AuthService.java | 108 ++++++++++++++++ .../src/main/resources/application.yml | 46 ++++--- 17 files changed, 718 insertions(+), 78 deletions(-) create mode 100644 gateway-service/src/main/java/com/posthub/gateway/config/AuthProperties.java create mode 100644 gateway-service/src/main/java/com/posthub/gateway/config/R2dbcConfig.java create mode 100644 gateway-service/src/main/java/com/posthub/gateway/config/SecurityConfig.java create mode 100644 gateway-service/src/main/java/com/posthub/gateway/controller/AuthController.java delete mode 100644 gateway-service/src/main/java/com/posthub/gateway/filter/AuthFilter.java create mode 100644 gateway-service/src/main/java/com/posthub/gateway/filter/JwtAuthGlobalFilter.java create mode 100644 gateway-service/src/main/java/com/posthub/gateway/model/entity/User.java create mode 100644 gateway-service/src/main/java/com/posthub/gateway/model/enums/RegistrationStatus.java create mode 100644 gateway-service/src/main/java/com/posthub/gateway/model/enums/UserRole.java create mode 100644 gateway-service/src/main/java/com/posthub/gateway/model/request/LoginUserRequest.java create mode 100644 gateway-service/src/main/java/com/posthub/gateway/model/request/RegistrationUserRequest.java create mode 100644 gateway-service/src/main/java/com/posthub/gateway/model/response/JwtResponse.java create mode 100644 gateway-service/src/main/java/com/posthub/gateway/repository/UserRepository.java create mode 100644 gateway-service/src/main/java/com/posthub/gateway/security/JwtTokenProvider.java create mode 100644 gateway-service/src/main/java/com/posthub/gateway/service/AuthService.java 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