From 67197064067e603fcd513249f0da3d0e57050eeb Mon Sep 17 00:00:00 2001 From: Andrey Eryomenko Date: Fri, 30 May 2025 00:09:28 +0300 Subject: [PATCH 01/12] add registration and authorization with GitHub --- build.gradle.kts | 3 + .../typoreporter/HexletTypoReporter.java | 5 ++ .../typoreporter/config/SecurityConfig.java | 16 ++++- .../typoreporter/service/AccountService.java | 19 ++++- .../service/account/signup/SignupAccount.java | 3 +- .../dto/account/CustomUserDetails.java | 2 +- .../oauth2/CustomOAuth2UserService.java | 66 +++++++++++++++++ .../service/oauth2/GithubOAuth2UserInfo.java | 70 +++++++++++++++++++ .../service/oauth2/OAuth2UserInfo.java | 12 ++++ .../service/oauth2/OAuth2UserInfoFactory.java | 14 ++++ .../web/model/SignupAccountModel.java | 2 + src/main/resources/config/application.yml | 20 ++++++ src/main/resources/messages_en.properties | 4 ++ src/main/resources/messages_ru.properties | 4 ++ .../resources/templates/account/signup.html | 5 ++ .../resources/templates/fragments/panels.html | 2 +- src/main/resources/templates/login.html | 5 ++ .../io/hexlet/typoreporter/web/LoginIT.java | 3 +- .../typoreporter/web/SignupControllerIT.java | 6 +- src/test/resources/config/application.yml | 4 ++ 20 files changed, 257 insertions(+), 8 deletions(-) create mode 100644 src/main/java/io/hexlet/typoreporter/service/oauth2/CustomOAuth2UserService.java create mode 100644 src/main/java/io/hexlet/typoreporter/service/oauth2/GithubOAuth2UserInfo.java create mode 100644 src/main/java/io/hexlet/typoreporter/service/oauth2/OAuth2UserInfo.java create mode 100644 src/main/java/io/hexlet/typoreporter/service/oauth2/OAuth2UserInfoFactory.java diff --git a/build.gradle.kts b/build.gradle.kts index 51116cb6..da43ee6c 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -32,6 +32,8 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-thymeleaf") implementation("org.springframework.boot:spring-boot-starter-actuator") implementation("org.springframework.boot:spring-boot-starter-validation") + implementation("org.springframework.boot:spring-boot-starter-oauth2-client") + implementation("org.springframework.boot:spring-boot-starter-webflux") runtimeOnly("org.springframework.boot:spring-boot-devtools") // Thymeleaf implementation("org.thymeleaf.extras:thymeleaf-extras-springsecurity6:3.1.1.RELEASE") @@ -47,6 +49,7 @@ dependencies { compileOnly("org.projectlombok:lombok-mapstruct-binding:0.2.0") implementation("org.ocpsoft.prettytime:prettytime:5.0.6.Final") implementation("org.mapstruct:mapstruct:1.5.3.Final") + implementation("io.github.cdimascio:dotenv-java:3.2.0") // Annotation processors annotationProcessor("org.mapstruct:mapstruct-processor:1.5.3.Final") // Testing diff --git a/src/main/java/io/hexlet/typoreporter/HexletTypoReporter.java b/src/main/java/io/hexlet/typoreporter/HexletTypoReporter.java index d9419813..7697944f 100644 --- a/src/main/java/io/hexlet/typoreporter/HexletTypoReporter.java +++ b/src/main/java/io/hexlet/typoreporter/HexletTypoReporter.java @@ -1,5 +1,6 @@ package io.hexlet.typoreporter; +import io.github.cdimascio.dotenv.Dotenv; import lombok.extern.slf4j.Slf4j; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @@ -15,6 +16,10 @@ public class HexletTypoReporter { public static void main(String[] args) { + Dotenv dotenv = Dotenv.load(); + System.setProperty("GITHUB_CLIENT_ID", dotenv.get("GITHUB_CLIENT_ID")); + System.setProperty("GITHUB_CLIENT_SECRET", dotenv.get("GITHUB_CLIENT_SECRET")); + final var env = SpringApplication.run(HexletTypoReporter.class, args).getEnvironment(); logApplicationStartup(env); } diff --git a/src/main/java/io/hexlet/typoreporter/config/SecurityConfig.java b/src/main/java/io/hexlet/typoreporter/config/SecurityConfig.java index a93ba4e1..072a0c2b 100644 --- a/src/main/java/io/hexlet/typoreporter/config/SecurityConfig.java +++ b/src/main/java/io/hexlet/typoreporter/config/SecurityConfig.java @@ -4,10 +4,12 @@ import io.hexlet.typoreporter.handler.exception.WorkspaceNotFoundException; import io.hexlet.typoreporter.security.service.AccountDetailService; import io.hexlet.typoreporter.security.service.SecuredWorkspaceService; +import io.hexlet.typoreporter.service.oauth2.CustomOAuth2UserService; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.authentication.AuthenticationManager; @@ -38,6 +40,9 @@ @EnableMethodSecurity public class SecurityConfig { + @Value("${spring.security.oauth2.enable:true}") + private boolean oauth2Enable; + @Bean public PasswordEncoder bCryptPasswordEncoder() { return new BCryptPasswordEncoder(); @@ -78,7 +83,8 @@ public SecurityContextRepository securityContextRepository() { @Bean public SecurityFilterChain filterChain(HttpSecurity http, SecurityContextRepository securityContextRepository, - DynamicCorsConfigurationSource dynamicCorsConfigurationSource) throws Exception { + DynamicCorsConfigurationSource dynamicCorsConfigurationSource, + CustomOAuth2UserService customOAuth2UserService) throws Exception { http.httpBasic(); http.cors(); http.exceptionHandling().accessDeniedHandler(accessDeniedHandler()); @@ -103,6 +109,14 @@ public SecurityFilterChain filterChain(HttpSecurity http, ) .addFilterBefore(corsFilter(dynamicCorsConfigurationSource), CorsFilter.class); + if (oauth2Enable) { + http.oauth2Login(oauth -> oauth + .loginPage("/login") + .userInfoEndpoint(userInfo -> + userInfo.userService(customOAuth2UserService)) + .defaultSuccessUrl("/workspaces", true)); + } + http.securityContext().securityContextRepository(securityContextRepository); http.headers().frameOptions().disable(); diff --git a/src/main/java/io/hexlet/typoreporter/service/AccountService.java b/src/main/java/io/hexlet/typoreporter/service/AccountService.java index 70b5d097..713d44b3 100644 --- a/src/main/java/io/hexlet/typoreporter/service/AccountService.java +++ b/src/main/java/io/hexlet/typoreporter/service/AccountService.java @@ -8,6 +8,7 @@ import io.hexlet.typoreporter.service.account.UsernameAlreadyExistException; import io.hexlet.typoreporter.service.account.signup.SignupAccount; import io.hexlet.typoreporter.service.account.signup.SignupAccountUseCase; +import io.hexlet.typoreporter.service.dto.account.CustomUserDetails; import io.hexlet.typoreporter.service.dto.account.InfoAccount; import io.hexlet.typoreporter.service.dto.account.UpdatePassword; import io.hexlet.typoreporter.service.dto.account.UpdateProfile; @@ -20,6 +21,9 @@ import io.hexlet.typoreporter.handler.exception.NewPasswordTheSameException; import io.hexlet.typoreporter.handler.exception.OldPasswordWrongException; import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -66,7 +70,20 @@ public InfoAccount signup(SignupAccount signupAccount) throws UsernameAlreadyExi accToSave.setEmail(normalizedEmail); accToSave.setUsername(normalizedUsername); accToSave.setPassword(passwordEncoder.encode(signupAccount.password())); - accToSave.setAuthProvider(AuthProvider.EMAIL); + if (accToSave.getAuthProvider() == null) { + accToSave.setAuthProvider(AuthProvider.EMAIL); + } + + CustomUserDetails accountDetail = new CustomUserDetails( + normalizedEmail, + accToSave.getPassword(), + normalizedUsername, + List.of(new SimpleGrantedAuthority("ROLE_USER")) + ); + + var tempAuth = new UsernamePasswordAuthenticationToken(accountDetail, null, accountDetail.getAuthorities()); + SecurityContextHolder.getContext().setAuthentication(tempAuth); + accountRepository.save(accToSave); return accountMapper.toInfoAccount(accToSave); } diff --git a/src/main/java/io/hexlet/typoreporter/service/account/signup/SignupAccount.java b/src/main/java/io/hexlet/typoreporter/service/account/signup/SignupAccount.java index cd8f120c..08450647 100644 --- a/src/main/java/io/hexlet/typoreporter/service/account/signup/SignupAccount.java +++ b/src/main/java/io/hexlet/typoreporter/service/account/signup/SignupAccount.java @@ -5,7 +5,8 @@ public record SignupAccount( String email, String password, String firstName, - String lastName + String lastName, + String authProvider ) { @Override diff --git a/src/main/java/io/hexlet/typoreporter/service/dto/account/CustomUserDetails.java b/src/main/java/io/hexlet/typoreporter/service/dto/account/CustomUserDetails.java index 05ab5ed2..bb4dd88b 100644 --- a/src/main/java/io/hexlet/typoreporter/service/dto/account/CustomUserDetails.java +++ b/src/main/java/io/hexlet/typoreporter/service/dto/account/CustomUserDetails.java @@ -11,7 +11,7 @@ public class CustomUserDetails implements UserDetails { private String email; private String password; @Getter - private final String nickname; + private final String name; private Collection authorities; @Override diff --git a/src/main/java/io/hexlet/typoreporter/service/oauth2/CustomOAuth2UserService.java b/src/main/java/io/hexlet/typoreporter/service/oauth2/CustomOAuth2UserService.java new file mode 100644 index 00000000..4b3cccab --- /dev/null +++ b/src/main/java/io/hexlet/typoreporter/service/oauth2/CustomOAuth2UserService.java @@ -0,0 +1,66 @@ +package io.hexlet.typoreporter.service.oauth2; + +import io.hexlet.typoreporter.domain.account.AuthProvider; +import io.hexlet.typoreporter.service.AccountService; +import io.hexlet.typoreporter.service.account.signup.SignupAccount; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserService; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.user.DefaultOAuth2User; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.stereotype.Service; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Service +@RequiredArgsConstructor +public class CustomOAuth2UserService implements OAuth2UserService { + + private final AccountService accountService; + + @Override + public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { + + OAuth2User oAuth2User = new DefaultOAuth2UserService().loadUser(userRequest); + String oAuth2Provider = userRequest.getClientRegistration().getRegistrationId().toUpperCase(); + String accessToken = userRequest.getAccessToken().getTokenValue(); + Map oAuth2UserAttributes = oAuth2User.getAttributes(); + + OAuth2UserInfo oAuth2UserInfo = OAuth2UserInfoFactory.getOAuth2UserInfo( + oAuth2Provider, accessToken, oAuth2UserAttributes); + + String email = oAuth2UserInfo.getEmail(); + + if (email == null) { + throw new OAuth2AuthenticationException("Email from provider " + oAuth2Provider + " not received"); + } + + if (!accountService.existsByEmail(email)) { + var newAccount = new SignupAccount( + oAuth2UserInfo.getUsername(), + email, + "OAUTH2_USER", + oAuth2UserInfo.getFirstName(), + oAuth2UserInfo.getLastName(), + AuthProvider.valueOf(oAuth2Provider).name() + ); + var createdAccount = accountService.signup(newAccount); + } + + if (oAuth2UserAttributes.get("email") == null) { + oAuth2UserAttributes = new HashMap<>(oAuth2UserAttributes); + oAuth2UserAttributes.put("email", email); + } + + return new DefaultOAuth2User( + List.of(new SimpleGrantedAuthority("ROLE_USER")), + oAuth2UserAttributes, + "email" + ); + } +} diff --git a/src/main/java/io/hexlet/typoreporter/service/oauth2/GithubOAuth2UserInfo.java b/src/main/java/io/hexlet/typoreporter/service/oauth2/GithubOAuth2UserInfo.java new file mode 100644 index 00000000..5aa004d5 --- /dev/null +++ b/src/main/java/io/hexlet/typoreporter/service/oauth2/GithubOAuth2UserInfo.java @@ -0,0 +1,70 @@ +package io.hexlet.typoreporter.service.oauth2; + +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpHeaders; +import org.springframework.web.reactive.function.client.WebClient; + +import java.util.List; +import java.util.Map; + +public class GithubOAuth2UserInfo implements OAuth2UserInfo { + + private final String accessToken; + private final Map attributes; + + public GithubOAuth2UserInfo(String accessToken, Map attributes) { + this.accessToken = accessToken; + this.attributes = attributes; + } + + @Override + public String getEmail() { + var email = attributes.get("email"); + if (email == null) { + WebClient webClient = WebClient.builder() + .baseUrl("https://api.github.com") + .defaultHeader(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) + .build(); + + List> emails = webClient.get() + .uri("/user/emails") + .retrieve() + .bodyToMono(new ParameterizedTypeReference>>() { }) + .block(); + + email = emails.stream() + .filter(e -> Boolean.TRUE.equals(e.get("primary"))) + .map(e -> (String) e.get("email")) + .findFirst() + .orElse(null); + } + return (String) email; + } + + @Override + public String getUsername() { + return attributes.get("login").toString(); + } + + @Override + public String getPassword() { + return ""; + } + + @Override + public String getFirstName() { + String[] names = attributes.get("name").toString().split(" "); + return names.length > 0 ? names[0] : ""; + } + + @Override + public String getLastName() { + String[] names = attributes.get("name").toString().split(" "); + return names.length > 1 ? names[1] : ""; + } + + @Override + public Map getAttributes() { + return attributes; + } +} diff --git a/src/main/java/io/hexlet/typoreporter/service/oauth2/OAuth2UserInfo.java b/src/main/java/io/hexlet/typoreporter/service/oauth2/OAuth2UserInfo.java new file mode 100644 index 00000000..f471f647 --- /dev/null +++ b/src/main/java/io/hexlet/typoreporter/service/oauth2/OAuth2UserInfo.java @@ -0,0 +1,12 @@ +package io.hexlet.typoreporter.service.oauth2; + +import java.util.Map; + +public interface OAuth2UserInfo { + String getEmail(); + String getUsername(); + String getPassword(); + String getFirstName(); + String getLastName(); + Map getAttributes(); +} diff --git a/src/main/java/io/hexlet/typoreporter/service/oauth2/OAuth2UserInfoFactory.java b/src/main/java/io/hexlet/typoreporter/service/oauth2/OAuth2UserInfoFactory.java new file mode 100644 index 00000000..17631e82 --- /dev/null +++ b/src/main/java/io/hexlet/typoreporter/service/oauth2/OAuth2UserInfoFactory.java @@ -0,0 +1,14 @@ +package io.hexlet.typoreporter.service.oauth2; + +import java.util.Map; + +public class OAuth2UserInfoFactory { + + public static OAuth2UserInfo getOAuth2UserInfo(String provider, String accessToken, + Map attributes) { + return switch (provider.toUpperCase()) { + case "GITHUB" -> new GithubOAuth2UserInfo(accessToken, attributes); + default -> throw new IllegalArgumentException("Unsupported provider: " + provider); + }; + } +} diff --git a/src/main/java/io/hexlet/typoreporter/web/model/SignupAccountModel.java b/src/main/java/io/hexlet/typoreporter/web/model/SignupAccountModel.java index c67bd586..66d11c90 100644 --- a/src/main/java/io/hexlet/typoreporter/web/model/SignupAccountModel.java +++ b/src/main/java/io/hexlet/typoreporter/web/model/SignupAccountModel.java @@ -42,4 +42,6 @@ public class SignupAccountModel { @Size(max = 50) private String lastName; + + private String authProvider; } diff --git a/src/main/resources/config/application.yml b/src/main/resources/config/application.yml index a4410433..690d4d04 100644 --- a/src/main/resources/config/application.yml +++ b/src/main/resources/config/application.yml @@ -1,6 +1,26 @@ spring: application: name: hexletTypoReporter + profiles: + active: dev + security: + oauth2: + client: + registration: + github: + client-id: ${GITHUB_CLIENT_ID} + client-secret: ${GITHUB_CLIENT_SECRET} + scope: + - read:user + - user:email + redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}" + client-name: GitHub + provider: + github: + authorization-uri: https://github.com/login/oauth/authorize + token-uri: https://github.com/login/oauth/access_token + user-info-uri: https://api.github.com/user + user-name-attribute: login jpa: open-in-view: false hibernate: diff --git a/src/main/resources/messages_en.properties b/src/main/resources/messages_en.properties index 089e6c11..90e3fef9 100644 --- a/src/main/resources/messages_en.properties +++ b/src/main/resources/messages_en.properties @@ -144,3 +144,7 @@ text.about-header=About project text.about-welcome=Welcome to the open-source project FixIT text.about-description=The service for notifying website owners about errors and typos. After integration with the site, visitors have the opportunity to highlight an error or a typo and report it to the administrator. The project runs in Java. text.about-community=Tasks can be discussed in the community + +# Registration and authentication with social +link.github-create-account=Create Account with GitHub +link.github-login=Login with GitHub diff --git a/src/main/resources/messages_ru.properties b/src/main/resources/messages_ru.properties index 308e6e74..a2de9cb8 100644 --- a/src/main/resources/messages_ru.properties +++ b/src/main/resources/messages_ru.properties @@ -142,3 +142,7 @@ text.about-header=О проекте text.about-welcome=Добро пожаловать в open-source проект FixIT text.about-description=Сервис для уведомления владельцев сайтов об ошибках и опечатках. После интеграции с сайтом посетители имеют возможность выделить ошибку или опечатку и сообщить об этом администратору. Проект работает на Java. text.about-community=Задачи можно обсудить в сообществе + +# Registration and authentication with social +link.github-create-account=Регистрация с помощью GitHub +link.github-login=Войти с помощью GitHub diff --git a/src/main/resources/templates/account/signup.html b/src/main/resources/templates/account/signup.html index a5509078..33bc7899 100644 --- a/src/main/resources/templates/account/signup.html +++ b/src/main/resources/templates/account/signup.html @@ -50,5 +50,10 @@ +
+
+ +
+
diff --git a/src/main/resources/templates/fragments/panels.html b/src/main/resources/templates/fragments/panels.html index 026d6cb1..06a1b678 100644 --- a/src/main/resources/templates/fragments/panels.html +++ b/src/main/resources/templates/fragments/panels.html @@ -41,7 +41,7 @@