diff --git a/src/main/java/com/example/dateplanner/configurations/AppConfig.java b/src/main/java/com/example/dateplanner/configurations/AppConfig.java new file mode 100644 index 0000000..965ea82 --- /dev/null +++ b/src/main/java/com/example/dateplanner/configurations/AppConfig.java @@ -0,0 +1,51 @@ +package com.example.dateplanner.configurations; + +import com.example.dateplanner.models.entities.AppUser; +import com.example.dateplanner.models.enums.Role; +import com.example.dateplanner.repositories.AppUserRepository; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.CommandLineRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.crypto.password.PasswordEncoder; +import reactor.core.publisher.Mono; + +@Slf4j +@Configuration +public class AppConfig { + + private final String username = "morgan"; + private final String password = "Admin_123!"; + + @Bean + public CommandLineRunner prepare(AppUserRepository userRepository, PasswordEncoder encoder) { + return args -> { + log.info("***************** environments start *******************"); + for (String key : System.getenv().keySet()) { + String[] p = key.split("\\."); + if (p.length > 1) { + log.info("\"{}\"=\"{}\"", key, System.getenv().get(key)); + } + } + log.info("****************** environments end ********************"); + userRepository.findByPhone(username).flatMap(existingUser -> { + log.info("User {} exist.", existingUser.getPhone()); + return Mono.just(existingUser); + }).switchIfEmpty( + Mono.defer(() -> { + log.info("User not found! Create default admin user: {}, password: {}", username,password); + AppUser user = new AppUser(); + user.setPhone(username); + user.setPassword(encoder.encode(password)); + user.setLastName("Admin"); + user.setFirstName("Master"); + user.setRole(Role.ADMIN); + return userRepository.save(user); + }) + ).flatMap(u -> { + log.info("User in db: [{}]", u); + return Mono.empty(); + }).subscribe(); + }; + } +} diff --git a/src/main/java/com/example/dateplanner/configurations/SecurityConfig.java b/src/main/java/com/example/dateplanner/configurations/SecurityConfig.java index 7657ad2..f055268 100644 --- a/src/main/java/com/example/dateplanner/configurations/SecurityConfig.java +++ b/src/main/java/com/example/dateplanner/configurations/SecurityConfig.java @@ -1,15 +1,38 @@ package com.example.dateplanner.configurations; +import com.example.dateplanner.configurations.filters.ErrorHandlingFilter; +import com.example.dateplanner.configurations.handlers.JwtAuthenticationConverter; +import com.example.dateplanner.configurations.handlers.JwtAuthenticationManager; +import com.example.dateplanner.configurations.handlers.JwtAuthenticationSuccessHandler; +import com.example.dateplanner.configurations.handlers.JwtLogoutSuccessHandler; +import com.example.dateplanner.repositories.AppUserRepository; +import com.example.dateplanner.services.JwtService; +import com.example.dateplanner.services.UserService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.annotation.Order; +import org.springframework.security.authentication.ReactiveAuthenticationManager; +import org.springframework.security.authentication.UserDetailsRepositoryReactiveAuthenticationManager; +import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.method.configuration.EnableReactiveMethodSecurity; import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity; +import org.springframework.security.config.web.server.SecurityWebFiltersOrder; import org.springframework.security.config.web.server.ServerHttpSecurity; +import org.springframework.security.core.userdetails.ReactiveUserDetailsPasswordService; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.server.SecurityWebFilterChain; +import org.springframework.security.web.server.authentication.AuthenticationWebFilter; +import org.springframework.security.web.server.csrf.CookieServerCsrfTokenRepository; +import org.springframework.security.web.server.csrf.ServerCsrfTokenRequestAttributeHandler; +import org.springframework.security.web.server.savedrequest.ServerRequestCache; +import org.springframework.security.web.server.savedrequest.WebSessionServerRequestCache; import org.springframework.security.web.server.util.matcher.PathPatternParserServerWebExchangeMatcher; +import reactor.core.publisher.Mono; +import java.net.URI; @Slf4j @Configuration @@ -17,49 +40,53 @@ import org.springframework.security.web.server.util.matcher.PathPatternParserSer @RequiredArgsConstructor @EnableReactiveMethodSecurity public class SecurityConfig { + + private final AppUserRepository userRepository; + private final JwtService jwtService; + // Цепочка для API (Basic Auth) - CSRF отключаем -// @Bean -// @Order(1) -// public SecurityWebFilterChain apiFilterChain(ServerHttpSecurity http) { -// return http -// .securityMatcher(new PathPatternParserServerWebExchangeMatcher("/api/**")) -// .authorizeExchange(exchange -> exchange -// .pathMatchers("/api/mobile/login","/api/news/paged", "/api/documents/paged").permitAll() -// .anyExchange().authenticated() -// ) -// .httpBasic(Customizer.withDefaults()) -// .formLogin(ServerHttpSecurity.FormLoginSpec::disable) -// .csrf(ServerHttpSecurity.CsrfSpec::disable) // CSRF отключаем для API -// .authenticationManager(reactiveAuthenticationManager()) -// .build(); -// } -// -// // Цепочка для веб-интерфейса (Form Login) - CSRF включаем -// @Bean -// @Order(2) -// public SecurityWebFilterChain webFilterChain(ServerHttpSecurity http) { -// ServerCsrfTokenRequestAttributeHandler requestHandler = new ServerCsrfTokenRequestAttributeHandler(); -// requestHandler.setTokenFromMultipartDataEnabled(true); -// -// AuthenticationWebFilter authenticationWebFilter = new AuthenticationWebFilter(authenticationManager()); -// authenticationWebFilter.setServerAuthenticationConverter(authenticationConverter()); -// -// ErrorHandlingFilter errorHandlingFilter = new ErrorHandlingFilter(); -// -// return http -// .securityMatcher(new PathPatternParserServerWebExchangeMatcher("/account/**")) -// .csrf(csrf -> csrf.csrfTokenRequestHandler(requestHandler).csrfTokenRepository(CookieServerCsrfTokenRepository.withHttpOnlyFalse())) -// .addFilterAt(authenticationWebFilter, SecurityWebFiltersOrder.AUTHENTICATION) -// .addFilterAfter(errorHandlingFilter, SecurityWebFiltersOrder.AUTHENTICATION) -// .authorizeExchange(exchange -> exchange -// .pathMatchers("/account/login").permitAll() -// .anyExchange().authenticated() -// ) -// .formLogin(loginSpec -> loginSpec.loginPage("/account/login").authenticationSuccessHandler(authenticationSuccessHandler())) -// .logout(logoutSpec -> logoutSpec.logoutSuccessHandler(logoutSuccessHandler())) -// .requestCache(requestCacheSpec -> requestCacheSpec.requestCache(serverRequestCache())) -// .build(); -// } + @Bean + @Order(1) + public SecurityWebFilterChain apiFilterChain(ServerHttpSecurity http) { + return http + .securityMatcher(new PathPatternParserServerWebExchangeMatcher("/api/**")) + .authorizeExchange(exchange -> exchange + .pathMatchers("/api/mobile/login").permitAll() + .anyExchange().authenticated() + ) + .httpBasic(Customizer.withDefaults()) + .formLogin(ServerHttpSecurity.FormLoginSpec::disable) + .csrf(ServerHttpSecurity.CsrfSpec::disable) // CSRF отключаем для API + .authenticationManager(reactiveAuthenticationManager()) + .build(); + } + + // Цепочка для веб-интерфейса (Form Login) - CSRF включаем + @Bean + @Order(2) + public SecurityWebFilterChain webFilterChain(ServerHttpSecurity http) { + ServerCsrfTokenRequestAttributeHandler requestHandler = new ServerCsrfTokenRequestAttributeHandler(); + requestHandler.setTokenFromMultipartDataEnabled(true); + + AuthenticationWebFilter authenticationWebFilter = new AuthenticationWebFilter(authenticationManager()); + authenticationWebFilter.setServerAuthenticationConverter(authenticationConverter()); + + ErrorHandlingFilter errorHandlingFilter = new ErrorHandlingFilter(); + + return http + .securityMatcher(new PathPatternParserServerWebExchangeMatcher("/account/**")) + .csrf(csrf -> csrf.csrfTokenRequestHandler(requestHandler).csrfTokenRepository(CookieServerCsrfTokenRepository.withHttpOnlyFalse())) + .addFilterAt(authenticationWebFilter, SecurityWebFiltersOrder.AUTHENTICATION) + .addFilterAfter(errorHandlingFilter, SecurityWebFiltersOrder.AUTHENTICATION) + .authorizeExchange(exchange -> exchange + .pathMatchers("/account/login").permitAll() + .anyExchange().authenticated() + ) + .formLogin(loginSpec -> loginSpec.loginPage("/account/login").authenticationSuccessHandler(authenticationSuccessHandler())) + .logout(logoutSpec -> logoutSpec.logoutSuccessHandler(logoutSuccessHandler())) + .requestCache(requestCacheSpec -> requestCacheSpec.requestCache(serverRequestCache())) + .build(); + } // Цепочка для всех остальных путей (публичные) @Bean @@ -73,4 +100,62 @@ public class SecurityConfig { .csrf(ServerHttpSecurity.CsrfSpec::disable) .build(); } + + @Bean + public ServerRequestCache serverRequestCache() { + return new WebSessionServerRequestCache(); + } + + @Bean + public JwtAuthenticationManager authenticationManager() { + return new JwtAuthenticationManager(jwtService, userService(), passwordEncoder()); + } + + @Bean + public JwtAuthenticationSuccessHandler authenticationSuccessHandler(){ + return new JwtAuthenticationSuccessHandler(jwtService,serverRequestCache()); + } + + @Bean + public JwtLogoutSuccessHandler logoutSuccessHandler(){ + JwtLogoutSuccessHandler logoutSuccessHandler = new JwtLogoutSuccessHandler(); + logoutSuccessHandler.setLogoutSuccessUrl(URI.create("/")); + return logoutSuccessHandler; + } + + @Bean + public JwtAuthenticationConverter authenticationConverter(){ + return new JwtAuthenticationConverter(); + } + + @Bean + public ReactiveAuthenticationManager reactiveAuthenticationManager() { + UserDetailsRepositoryReactiveAuthenticationManager manager = new UserDetailsRepositoryReactiveAuthenticationManager(userService()); + manager.setPasswordEncoder(passwordEncoder()); + manager.setUserDetailsPasswordService(userDetailsPasswordService()); + return manager; + } + + @Bean + public ReactiveUserDetailsPasswordService userDetailsPasswordService() { + return new ReactiveUserDetailsPasswordService() { + @Override + public Mono updatePassword(UserDetails user, String newPassword) { + return userRepository.findByPhone(user.getUsername()).flatMap(appUser -> { + appUser.setPassword(passwordEncoder().encode(newPassword)); + return userRepository.save(appUser); + }); + } + }; + } + + @Bean + public UserService userService(){ + return new UserService(userRepository,passwordEncoder()); + } + + @Bean + public PasswordEncoder passwordEncoder(){ + return new BCryptPasswordEncoder(); + } } diff --git a/src/main/java/com/example/dateplanner/configurations/filters/ErrorHandlingFilter.java b/src/main/java/com/example/dateplanner/configurations/filters/ErrorHandlingFilter.java new file mode 100644 index 0000000..6f962a8 --- /dev/null +++ b/src/main/java/com/example/dateplanner/configurations/filters/ErrorHandlingFilter.java @@ -0,0 +1,50 @@ +package com.example.dateplanner.configurations.filters; + +import lombok.extern.slf4j.Slf4j; +import org.jetbrains.annotations.NotNull; +import org.springframework.http.HttpStatus; +import org.springframework.http.HttpStatusCode; +import org.springframework.web.server.ResponseStatusException; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.WebFilter; +import org.springframework.web.server.WebFilterChain; +import reactor.core.publisher.Mono; + +import java.net.URI; + +@Slf4j +public class ErrorHandlingFilter implements WebFilter { + @Override + public @NotNull Mono filter(@NotNull ServerWebExchange exchange, WebFilterChain chain) { + //log.info("error handler!"); + return chain.filter(exchange) + .onErrorResume(error -> handleError(exchange, error)) + .then(Mono.defer(() -> handleNotFound(exchange))); + } + + private Mono handleError(ServerWebExchange exchange, Throwable error) { + HttpStatusCode status = determineStatusCode(error); + return prepareRedirect(exchange, status); + } + + private Mono handleNotFound(ServerWebExchange exchange) { + if (exchange.getResponse().getStatusCode() == null) { + return prepareRedirect(exchange, HttpStatus.NOT_FOUND); + } + return Mono.empty(); + } + + private Mono prepareRedirect(ServerWebExchange exchange, HttpStatusCode status) { + exchange.getResponse().setStatusCode(HttpStatus.FOUND); // 302 Redirect + String redirectUrl = "/error?status=" + status.value(); + exchange.getResponse().getHeaders().setLocation(URI.create(redirectUrl)); + return exchange.getResponse().setComplete(); + } + + private HttpStatusCode determineStatusCode(Throwable error) { + if (error instanceof ResponseStatusException) { + return ((ResponseStatusException) error).getStatusCode(); + } + return HttpStatus.INTERNAL_SERVER_ERROR; + } +} diff --git a/src/main/java/com/example/dateplanner/configurations/handlers/JwtAuthenticationConverter.java b/src/main/java/com/example/dateplanner/configurations/handlers/JwtAuthenticationConverter.java new file mode 100644 index 0000000..e073d93 --- /dev/null +++ b/src/main/java/com/example/dateplanner/configurations/handlers/JwtAuthenticationConverter.java @@ -0,0 +1,34 @@ +package com.example.dateplanner.configurations.handlers; + +import com.example.dateplanner.utils.CookieUtil; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpCookie; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.server.authentication.ServerAuthenticationConverter; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; + +@Slf4j +public class JwtAuthenticationConverter implements ServerAuthenticationConverter { + @Override + public Mono convert(ServerWebExchange exchange) { + //log.info("start auth converter"); + String accessToken = extractTokenFromCookie(exchange.getRequest(), CookieUtil.getInstance().getACCESS()); + String refreshToken = extractTokenFromCookie(exchange.getRequest(), CookieUtil.getInstance().getREFRESH()); + //log.info("access and refresh tokens: {},{}",accessToken,refreshToken); + if(accessToken.equals("") && refreshToken.equals("")){ + //log.info("token not found"); + return Mono.empty(); + }else{ + //log.info("found access: {}, and refresh: {} tokens", accessToken,refreshToken); + return Mono.just(new UsernamePasswordAuthenticationToken(accessToken, refreshToken)); + } + } + + private String extractTokenFromCookie(ServerHttpRequest request, String cookieName) { + HttpCookie cookie = request.getCookies().getFirst(cookieName); + return cookie != null ? cookie.getValue() : ""; + } +} diff --git a/src/main/java/com/example/dateplanner/configurations/handlers/JwtAuthenticationManager.java b/src/main/java/com/example/dateplanner/configurations/handlers/JwtAuthenticationManager.java new file mode 100644 index 0000000..f45208e --- /dev/null +++ b/src/main/java/com/example/dateplanner/configurations/handlers/JwtAuthenticationManager.java @@ -0,0 +1,119 @@ +package com.example.dateplanner.configurations.handlers; + +import com.example.dateplanner.services.JwtService; +import com.example.dateplanner.services.UserService; +import com.example.dateplanner.utils.CookieUtil; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseCookie; +import org.springframework.http.server.reactive.ServerHttpResponse; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.ReactiveAuthenticationManager; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; +import java.time.Duration; + +@Slf4j +@RequiredArgsConstructor +public class JwtAuthenticationManager implements ReactiveAuthenticationManager { + + private final JwtService jwt; + private final UserService userService; + private final PasswordEncoder encoder; + + @Override + public Mono authenticate(Authentication authentication) { + //log.info("start auth manager"); + String accessToken = authentication.getPrincipal().toString(); + String refreshToken = authentication.getCredentials().toString(); + log.info("refresh is {},{}", accessToken, refreshToken); + + if(jwt.validateToken(accessToken)){ + //log.info("access token is valid"); + return Mono.deferContextual(contextView -> { + ServerWebExchange exchange = contextView.get(ServerWebExchange.class); + String digitalSignature = exchange.getRequest().getHeaders().getFirst("User-Agent"); + assert digitalSignature != null; + if(digitalSignature.equals(jwt.getDigitalSignatureFromToken(accessToken))) { + //log.info("the correct digital signature has been presented"); + return userService.findByUsername(jwt.getUsernameFromToken(accessToken)).flatMap(user -> Mono.just(new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities()))); + }else{ + //log.error("the digital signature does not match the signature in the token [{}] | [{}]", digitalSignature, jwt.getDigitalSignatureFromToken(accessToken)); + ServerHttpResponse response = exchange.getResponse(); + response.addCookie(ResponseCookie.from(CookieUtil.getInstance().getACCESS(), "") + .httpOnly(true) + .path("/") + .maxAge(0) + .build()); + response.addCookie(ResponseCookie.from(CookieUtil.getInstance().getREFRESH(), "") + .httpOnly(true) + .path("/") + .maxAge(0) + .build()); + response.addCookie(ResponseCookie.from(CookieUtil.getInstance().getSESSION(), "") + .httpOnly(true) + .path("/") + .maxAge(0) + .build()); + return Mono.error(new BadCredentialsException("Authentication failed")); + } + }); + }else if(jwt.validateToken(refreshToken)){ + //log.info("access token is not valid, but refresh token is ok"); + return Mono.deferContextual(contextView -> { + ServerWebExchange exchange = contextView.get(ServerWebExchange.class); + String digitalSignature = exchange.getRequest().getHeaders().getFirst("User-Agent"); + assert digitalSignature != null; + if(digitalSignature.equals(jwt.getDigitalSignatureFromToken(refreshToken))) { + //log.info("digital signature of refresh token is ok - update jwt"); + return userService.findByUsername(jwt.getUsernameFromToken(refreshToken)).flatMap(user -> { + String username = user.getUsername(); + String newAccessToken = jwt.generateAccessToken(username, digitalSignature); + String newRefreshToken = jwt.generateRefreshToken(username, digitalSignature); + ResponseCookie accessCookie = ResponseCookie.from(CookieUtil.getInstance().getACCESS(), newAccessToken) + .httpOnly(true) + .maxAge(Duration.ofMillis(jwt.getAccessExpiration())) + .path("/") + .build(); + ResponseCookie refreshCookie = ResponseCookie.from(CookieUtil.getInstance().getREFRESH(), newRefreshToken) + .httpOnly(true) + .maxAge(Duration.ofMillis(jwt.getRefreshExpiration())) + .path("/") + .build(); + + exchange.getResponse().addCookie(accessCookie); + exchange.getResponse().addCookie(refreshCookie); + return Mono.just(new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities())); + }).switchIfEmpty(Mono.error(new BadCredentialsException("Authentication failed"))); + }else{ + //log.error("the digital signature of refresh token does not match the signature in the token [{}] | [{}]", digitalSignature, jwt.getDigitalSignatureFromToken(refreshToken)); + ResponseCookie refreshCookie = ResponseCookie.from(CookieUtil.getInstance().getREFRESH(), "") + .httpOnly(true) + .path("/") + .maxAge(0) + .build(); + exchange.getResponse().addCookie(refreshCookie); + return baseAuth(authentication); + } + }); + }else{ + return baseAuth(authentication); + } + } + + private Mono baseAuth(Authentication authentication){ + log.info("access and refresh token is not valid - try authenticate by basic login"); + String username = authentication.getPrincipal().toString(); + String password = authentication.getCredentials().toString(); + return userService.findByUsername(username).flatMap(user -> { + if(encoder.matches(password,user.getPassword())){ + return Mono.just(new UsernamePasswordAuthenticationToken(user,null,user.getAuthorities())); + } else{ + return Mono.error(new BadCredentialsException("Authentication failed")); + } + }).cast(Authentication.class).switchIfEmpty(Mono.error(new BadCredentialsException("Authentication failed"))); + } +} diff --git a/src/main/java/com/example/dateplanner/configurations/handlers/JwtAuthenticationSuccessHandler.java b/src/main/java/com/example/dateplanner/configurations/handlers/JwtAuthenticationSuccessHandler.java new file mode 100644 index 0000000..3d4b82e --- /dev/null +++ b/src/main/java/com/example/dateplanner/configurations/handlers/JwtAuthenticationSuccessHandler.java @@ -0,0 +1,56 @@ +package com.example.dateplanner.configurations.handlers; + +import com.example.dateplanner.services.JwtService; +import com.example.dateplanner.utils.CookieUtil; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseCookie; +import org.springframework.http.server.reactive.ServerHttpResponse; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.server.WebFilterExchange; +import org.springframework.security.web.server.authentication.ServerAuthenticationSuccessHandler; +import org.springframework.security.web.server.savedrequest.ServerRequestCache; +import reactor.core.publisher.Mono; + +import java.net.URI; +import java.time.Duration; + +@Slf4j +@RequiredArgsConstructor +public class JwtAuthenticationSuccessHandler implements ServerAuthenticationSuccessHandler { + + private final JwtService jwt; + private final ServerRequestCache requestCache; + + @Override + public Mono onAuthenticationSuccess(WebFilterExchange webFilterExchange, Authentication authentication) { + //log.info("auth login success"); + String username = authentication.getName(); + String digitalSignature = webFilterExchange.getExchange().getRequest().getHeaders().getFirst("User-Agent"); + String accessToken = jwt.generateAccessToken(username, digitalSignature); + String refreshToken = jwt.generateRefreshToken(username, digitalSignature); + ResponseCookie accessCookie = ResponseCookie.from(CookieUtil.getInstance().getACCESS(), accessToken) + .httpOnly(true) + .maxAge(Duration.ofMillis(jwt.getAccessExpiration())) + .path("/") + .build(); + ResponseCookie refreshCookie = ResponseCookie.from(CookieUtil.getInstance().getREFRESH(), refreshToken) + .httpOnly(true) + .maxAge(Duration.ofMillis(jwt.getRefreshExpiration())) + .path("/") + .build(); + + ServerHttpResponse response = webFilterExchange.getExchange().getResponse(); + response.addCookie(accessCookie); + response.addCookie(refreshCookie); + + return requestCache.getRedirectUri(webFilterExchange.getExchange()) + .defaultIfEmpty(URI.create("/")) + .flatMap(redirectUri -> { + response.setStatusCode(HttpStatus.FOUND); + webFilterExchange.getExchange().getResponse().getHeaders().setLocation(redirectUri); + return webFilterExchange.getExchange().getResponse().setComplete(); + }); + } +} diff --git a/src/main/java/com/example/dateplanner/configurations/handlers/JwtLogoutSuccessHandler.java b/src/main/java/com/example/dateplanner/configurations/handlers/JwtLogoutSuccessHandler.java new file mode 100644 index 0000000..48d9338 --- /dev/null +++ b/src/main/java/com/example/dateplanner/configurations/handlers/JwtLogoutSuccessHandler.java @@ -0,0 +1,30 @@ +package com.example.dateplanner.configurations.handlers; + +import com.example.dateplanner.utils.CookieUtil; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseCookie; +import org.springframework.http.server.reactive.ServerHttpResponse; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.server.WebFilterExchange; +import org.springframework.security.web.server.authentication.logout.RedirectServerLogoutSuccessHandler; +import reactor.core.publisher.Mono; + +@Slf4j +public class JwtLogoutSuccessHandler extends RedirectServerLogoutSuccessHandler { + @Override + public Mono onLogoutSuccess(WebFilterExchange webFilterExchange, Authentication authentication) { + //log.info("auth logout success"); + ServerHttpResponse response = webFilterExchange.getExchange().getResponse(); + response.addCookie(ResponseCookie.from(CookieUtil.getInstance().getACCESS(), "") + .httpOnly(true) + .path("/") + .maxAge(0) + .build()); + response.addCookie(ResponseCookie.from(CookieUtil.getInstance().getREFRESH(), "") + .httpOnly(true) + .path("/") + .maxAge(0) + .build()); + return super.onLogoutSuccess(webFilterExchange, authentication); + } +} diff --git a/src/main/java/com/example/dateplanner/controllers/advice/SecurityAdviceController.java b/src/main/java/com/example/dateplanner/controllers/advice/SecurityAdviceController.java new file mode 100644 index 0000000..8da0ef7 --- /dev/null +++ b/src/main/java/com/example/dateplanner/controllers/advice/SecurityAdviceController.java @@ -0,0 +1,43 @@ +package com.example.dateplanner.controllers.advice; + +import com.example.dateplanner.models.entities.AppUser; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.PropertySource; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.web.csrf.CsrfToken; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; + +@ControllerAdvice +@PropertySource("classpath:application.properties") +public class SecurityAdviceController { + + @Value("${minio.cdn}") + private String cdn; + + @ModelAttribute(name = "cdn") + public Mono cdnUrl() { + return Mono.just(cdn); + } + + @ModelAttribute(name = "auth") + public Mono isAuthenticate(@AuthenticationPrincipal AppUser user){ + if(user != null){ + return Mono.just(true); + }else{ + return Mono.just(false); + } + } + + @ModelAttribute(name = "user") + public Mono provideUser(@AuthenticationPrincipal AppUser user){ + return Mono.justOrEmpty(user); + } + +// @ModelAttribute(name = "_csrf") +// public Mono provideCsrfToken(ServerWebExchange exchange) { +// return exchange.getAttribute(CsrfToken.class.getName()); +// } +} diff --git a/src/main/java/com/example/dateplanner/controllers/web/AccountController.java b/src/main/java/com/example/dateplanner/controllers/web/AccountController.java new file mode 100644 index 0000000..57f88dc --- /dev/null +++ b/src/main/java/com/example/dateplanner/controllers/web/AccountController.java @@ -0,0 +1,34 @@ +package com.example.dateplanner.controllers.web; + +import com.example.dateplanner.models.entities.AppUser; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.reactive.result.view.Rendering; +import reactor.core.publisher.Mono; +import java.util.HashMap; +import java.util.Map; + +@Slf4j +@Controller +@RequiredArgsConstructor +@RequestMapping("/account") +public class AccountController { + @GetMapping("/login") + public Mono loginPage() { + Map model = new HashMap<>(); + model.put("title", "Login"); + model.put("index", "login"); + return Mono.just( + Rendering.view("template") + .model(model) + .build() + ); + } + +} diff --git a/src/main/java/com/example/dateplanner/repositories/AppUserRepository.java b/src/main/java/com/example/dateplanner/repositories/AppUserRepository.java index 7ede104..4c1750a 100644 --- a/src/main/java/com/example/dateplanner/repositories/AppUserRepository.java +++ b/src/main/java/com/example/dateplanner/repositories/AppUserRepository.java @@ -2,9 +2,11 @@ package com.example.dateplanner.repositories; import com.example.dateplanner.models.entities.AppUser; import org.springframework.data.r2dbc.repository.R2dbcRepository; +import reactor.core.publisher.Mono; import java.util.UUID; public interface AppUserRepository extends R2dbcRepository { + Mono findByPhone(String phone); } diff --git a/src/main/java/com/example/dateplanner/services/AppUserService.java b/src/main/java/com/example/dateplanner/services/AppUserService.java deleted file mode 100644 index b3ae1d7..0000000 --- a/src/main/java/com/example/dateplanner/services/AppUserService.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.example.dateplanner.services; - -import com.example.dateplanner.repositories.AppUserRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class AppUserService { - private final AppUserRepository appUserRepository; - -} diff --git a/src/main/java/com/example/dateplanner/services/JwtService.java b/src/main/java/com/example/dateplanner/services/JwtService.java new file mode 100644 index 0000000..a5f5961 --- /dev/null +++ b/src/main/java/com/example/dateplanner/services/JwtService.java @@ -0,0 +1,82 @@ +package com.example.dateplanner.services; + +import com.example.dateplanner.utils.PasswordGenerator; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.security.Keys; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.util.Date; + +@Slf4j +@Getter +@Service +public class JwtService { + private final long accessExpiration; + private final long refreshExpiration; + private final SecretKey key; + + public JwtService(@Value("${jwt.secret}") String secret, @Value("${jwt.expiration.access}") long accessExpiration, @Value("${jwt.expiration.refresh}") long refreshExpiration){ + this.accessExpiration = accessExpiration; + this.refreshExpiration = refreshExpiration; + String secretKey = PasswordGenerator.generatePassword(64, PasswordGenerator.Complexity.HARD); + key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); + } + + public String generateAccessToken(String username, String digitalSignature){ + return Jwts.builder() + .setSubject(username) + .setIssuedAt(new Date(System.currentTimeMillis())) + .setExpiration(new Date(System.currentTimeMillis() + accessExpiration)) + .signWith(key, SignatureAlgorithm.HS512) + .claim("digital_signature",digitalSignature) + .compact(); + } + + public String generateRefreshToken(String username, String digitalSignature) { + return Jwts.builder() + .setSubject(username) + .setIssuedAt(new Date(System.currentTimeMillis())) + .setExpiration(new Date(System.currentTimeMillis() + refreshExpiration)) + .signWith(key, SignatureAlgorithm.HS512) + .claim("digital_signature",digitalSignature) + .compact(); + } + + public boolean validateToken(String token) { + try { + Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token); + return true; + } catch (JwtException | IllegalArgumentException e) { + return false; + } + } + + public String getUsernameFromToken(String token) { + try { + Claims claims = Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody(); + return claims.getSubject(); + } catch (JwtException | IllegalArgumentException e) { + return null; + } + } + + public String getDigitalSignatureFromToken(String token) { + try { + Claims claims = Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody(); + if (claims.containsKey("digital_signature")) { + return (String) claims.get("digital_signature"); + } else { + throw new IllegalArgumentException("Token does not contain digital_signature claim"); + } + } catch (JwtException | IllegalArgumentException e) { + return null; + } + } +} diff --git a/src/main/java/com/example/dateplanner/services/UserService.java b/src/main/java/com/example/dateplanner/services/UserService.java new file mode 100644 index 0000000..9fd2ff5 --- /dev/null +++ b/src/main/java/com/example/dateplanner/services/UserService.java @@ -0,0 +1,21 @@ +package com.example.dateplanner.services; + +import com.example.dateplanner.repositories.AppUserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.userdetails.ReactiveUserDetailsService; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Mono; + +@Service +@RequiredArgsConstructor +public class UserService implements ReactiveUserDetailsService { + private final AppUserRepository appUserRepository; + private final PasswordEncoder passwordEncoder; + + @Override + public Mono findByUsername(String username) { + return appUserRepository.findByPhone(username).flatMap(Mono::just).cast(UserDetails.class); + } +} diff --git a/src/main/java/com/example/dateplanner/utils/CookieUtil.java b/src/main/java/com/example/dateplanner/utils/CookieUtil.java new file mode 100644 index 0000000..bef4e9f --- /dev/null +++ b/src/main/java/com/example/dateplanner/utils/CookieUtil.java @@ -0,0 +1,27 @@ +package com.example.dateplanner.utils; + +import lombok.Getter; + +@Getter +public class CookieUtil { + private static CookieUtil instance = null; + + private final String REFRESH; + private final String ACCESS; + private final String SESSION; + + protected CookieUtil(){ + String appName = "pstweb"; + REFRESH = appName + "-refresh"; + ACCESS = appName + "-access"; + SESSION = appName + "-session"; + } + + public static CookieUtil getInstance(){ + if(instance == null){ + instance = new CookieUtil(); + } + return instance; + } + +} diff --git a/src/main/java/com/example/dateplanner/utils/PasswordGenerator.java b/src/main/java/com/example/dateplanner/utils/PasswordGenerator.java new file mode 100644 index 0000000..7b202c9 --- /dev/null +++ b/src/main/java/com/example/dateplanner/utils/PasswordGenerator.java @@ -0,0 +1,49 @@ +package com.example.dateplanner.utils; + +import java.util.Random; + +public class PasswordGenerator { + private static final String LOWERCASE_LETTERS = "abcdefghijklmnopqrstuvwxyz"; + private static final String UPPERCASE_LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + private static final String NUMBERS = "0123456789"; + private static final String SPECIAL_CHARACTERS = "!@#$%^&*()-_=+[]{}|;:,.<>?"; + + private static final String EASY_CHARACTERS = LOWERCASE_LETTERS + UPPERCASE_LETTERS; + private static final String MEDIUM_CHARACTERS = EASY_CHARACTERS + NUMBERS; + private static final String HARD_CHARACTERS = MEDIUM_CHARACTERS + SPECIAL_CHARACTERS; + + private static final Random RANDOM = new Random(); + + public enum Complexity { + EASY, MEDIUM, HARD + } + + public static String generatePassword(int length, Complexity complexity) { + if (length <= 0) { + throw new IllegalArgumentException("Password length must be greater than zero"); + } + + StringBuilder password = new StringBuilder(length); + String characters; + + switch (complexity) { + case EASY: + characters = EASY_CHARACTERS; + break; + case MEDIUM: + characters = MEDIUM_CHARACTERS; + break; + case HARD: + characters = HARD_CHARACTERS; + break; + default: + throw new IllegalArgumentException("Invalid complexity level"); + } + + for (int i = 0; i < length; i++) { + password.append(characters.charAt(RANDOM.nextInt(characters.length()))); + } + + return password.toString(); + } +} diff --git a/src/main/resources/db/migration/V1_0_0__init.sql b/src/main/resources/db/migration/V1_0_1__init.sql similarity index 97% rename from src/main/resources/db/migration/V1_0_0__init.sql rename to src/main/resources/db/migration/V1_0_1__init.sql index 6443d7f..610670e 100644 --- a/src/main/resources/db/migration/V1_0_0__init.sql +++ b/src/main/resources/db/migration/V1_0_1__init.sql @@ -16,8 +16,10 @@ CREATE TABLE organizations ( -- Таблица пользователей CREATE TABLE app_users ( uuid UUID PRIMARY KEY DEFAULT gen_random_uuid(), - username VARCHAR(100) NOT NULL UNIQUE, + phone VARCHAR(100) NOT NULL UNIQUE, password VARCHAR(255) NOT NULL, + first_name VARCHAR(100) NOT NULL, + last_name VARCHAR(100) NOT NULL, role VARCHAR(50) NOT NULL, dating_profile_uuid UUID, balance INTEGER NOT NULL DEFAULT 0, @@ -120,7 +122,7 @@ CREATE TRIGGER update_organizations_updated_at FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); -- Индексы для производительности -CREATE INDEX idx_app_users_username ON app_users(username); +CREATE INDEX idx_app_users_phone ON app_users(phone); CREATE INDEX idx_app_users_dating_profile_uuid ON app_users(dating_profile_uuid); CREATE INDEX idx_dating_places_organization_uuid ON dating_places(organization_uuid); CREATE INDEX idx_dating_places_dating_type ON dating_places(dating_type); diff --git a/src/main/resources/static/css/header.css b/src/main/resources/static/css/header.css index 96db8f9..238bb72 100644 --- a/src/main/resources/static/css/header.css +++ b/src/main/resources/static/css/header.css @@ -1,3 +1,42 @@ + +.user-avatar { + width: 36px; + height: 36px; + border-radius: 50%; + background: #e74c3c; + color: white; + display: flex; + align-items: center; + justify-content: center; + font-weight: bold; + font-size: 14px; + border: 2px solid white; + box-shadow: 0 2px 5px rgba(0,0,0,0.1); +} + +.user-info { + padding: 15px; + border-bottom: 1px solid #eee; + display: flex; + align-items: center; + gap: 10px; +} + +.dropdown-item { + padding: 10px 15px; + display: flex; + align-items: center; + gap: 10px; + transition: all 0.2s; +} + +.dropdown-item:hover { + background: rgba(231, 76, 60, 0.1) !important; + color: #e74c3c !important; +} + + + /* Модальное окно авторизации */ .auth-tabs { border: none ; @@ -82,4 +121,4 @@ .social-auth-btn.vk { color: #4C75A3; } .social-auth-btn.google { color: #DB4437; } -.social-auth-btn.yandex { color: #FF0000; } \ No newline at end of file +.social-auth-btn.yandex { color: #FF0000; } diff --git a/src/main/resources/static/css/main.css b/src/main/resources/static/css/main.css index 4b4f08d..8d18ad8 100644 --- a/src/main/resources/static/css/main.css +++ b/src/main/resources/static/css/main.css @@ -126,4 +126,4 @@ footer { .social-icon:hover { background: var(--love-red); transform: translateY(-3px); -} \ No newline at end of file +} diff --git a/src/main/resources/static/css/style.css b/src/main/resources/static/css/style.css index 93d2513..81d0c80 100644 --- a/src/main/resources/static/css/style.css +++ b/src/main/resources/static/css/style.css @@ -1,3 +1,3 @@ @import "fonts.css"; @import "main.css"; -@import "header.css"; \ No newline at end of file +@import "header.css"; diff --git a/src/main/resources/static/js/site/blocks/header.js b/src/main/resources/static/js/site/blocks/header.js index 7d87c34..f72517b 100644 --- a/src/main/resources/static/js/site/blocks/header.js +++ b/src/main/resources/static/js/site/blocks/header.js @@ -1,5 +1,46 @@ function initHeader($header){ console.log("init header date") + let authSection + if(!auth) { + authSection = ` +
+ +
+ `; + } else { + authSection = ` + + `; + } $header.append(` @@ -62,7 +102,7 @@ function initHeader($header){
-
+
diff --git a/src/main/resources/templates/blocks/deepseek_html_20260202_b82073.html b/src/main/resources/templates/blocks/deepseek_html_20260202_b82073.html new file mode 100644 index 0000000..53b0a8d --- /dev/null +++ b/src/main/resources/templates/blocks/deepseek_html_20260202_b82073.html @@ -0,0 +1,652 @@ + + + + + + DatePlanner - Идеальные места для свиданий + + + + + + + + + + +
+ +
❤️
+
❤️
+
❤️
+
❤️
+ +
+
+
+

Найдите идеальное место для свидания

+

Более 500 проверенных локаций: от романтических ужинов до экстремальных приключений. Подберите свидание по настроению, бюджету и интересам.

+ +
+
+ Свидание в парке +
+
+
+
+ + + +
+ +
+ + + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/templates/template.html b/src/main/resources/templates/template.html index b9ca0ef..856e6fe 100644 --- a/src/main/resources/templates/template.html +++ b/src/main/resources/templates/template.html @@ -10,6 +10,15 @@ +