diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..8169b982 --- /dev/null +++ b/.env.example @@ -0,0 +1,15 @@ +# Instead of "your_github_client_id_value" write the "client_id" value +# that you received when creating your OAuth2 app +GITHUB_CLIENT_ID=your_github_client_id_value + +# Instead of "your_github_client_secret_value" write the "client_secret" value +# that you received when creating your OAuth2 app +GITHUB_CLIENT_SECRET=your_github_client_secret_value + +# Instead of "your_yandex_client_id_value" write the "client_id" value +# that you received when creating your OAuth2 app +YANDEX_CLIENT_ID=your_yandex_client_id_value + +# Instead of "your_yandex_client_secret_value" write the "client_secret" value +# that you received when creating your OAuth2 app +YANDEX_CLIENT_SECRET=your_yandex_client_secret_value diff --git a/.github/workflows/github-ci.yml b/.github/workflows/github-ci.yml index 9201b6db..600964b3 100644 --- a/.github/workflows/github-ci.yml +++ b/.github/workflows/github-ci.yml @@ -13,11 +13,12 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 steps: - - uses: actions/checkout@v2 - - name: Set up JDK 19 - uses: actions/setup-java@v1 + - uses: actions/checkout@v4 + - name: Set up JDK 21 + uses: actions/setup-java@v2 with: - java-version: 19 + distribution: zulu + java-version: 21 - name: Run unit tests run: make test-unit-only - name: Run integration tests diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index a3222132..9b2e747a 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -34,10 +34,10 @@ jobs: - name: Setup Pages uses: actions/configure-pages@v3 - name: Upload artifact - uses: actions/upload-pages-artifact@v1 + uses: actions/upload-pages-artifact@v3 with: # Upload entire repository path: src/widget - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v2 + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore index 522ef351..7bd69fdc 100644 --- a/.gitignore +++ b/.gitignore @@ -441,3 +441,6 @@ fabric.properties # JPA Buddy settings /.jpb/ + +# Amplicode +amplicode.xml diff --git a/README.md b/README.md index f93c8b64..0a6a8462 100644 --- a/README.md +++ b/README.md @@ -14,9 +14,35 @@ Highlight text and press Ctrl+Enter Before you can build this project, you must install and configure the following dependencies on your machine: -1. Java 19 +1. Java 21 2. Docker, Docker Compose +### Yandex authorization + +To enable Yandex authorization, you need to register on [Yandex ID OAuth](https://oauth.yandex.ru/) and create your web application, +add `ClientID` and `Client secret` in your secret + +```bash +YANDEX_CLIENT_ID=your_yadex_client_id_values +YANDEX_CLIENT_SECRET=your_yandex_client_secret_values +``` + +### Registration/Authorization with GitHub + +For registration or/and authorization account with GitHub: +- Create OAuth app https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app. +- Authorization callback URL = {baseUrl}/login/oauth2/code/github, for example: http://localhost:8080/login/oauth2/code/github +- Get values the `Client ID` and `Client Secret` and add to environment variables in any known way. + +For example, you can create an `.env` file in the root of the project, where you can enter the names of variables +and their values as shown below: +```bash +GITHUB_CLIENT_ID=your_github_client_id_values +GITHUB_CLIENT_SECRET=your_github_client_secret_values +``` +A `.env.example` file has been created in the root of the project, which specifies the variables as they should be specified. For these variables, you need to specify the values you received. +You can copy this file and rename to `.env`, change and use. + ### Packaging as uber-jar To build the final jar: diff --git a/build.gradle.kts b/build.gradle.kts index 51116cb6..06128fb7 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,7 +1,7 @@ group = "io.hexlet" version = "0.0.1-SNAPSHOT" description = "Hexlet Typo Reporter" -java.sourceCompatibility = JavaVersion.VERSION_19 +java.sourceCompatibility = JavaVersion.VERSION_21 plugins { id("java") @@ -32,6 +32,9 @@ 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") + implementation("org.springframework.session:spring-session-core") runtimeOnly("org.springframework.boot:spring-boot-devtools") // Thymeleaf implementation("org.thymeleaf.extras:thymeleaf-extras-springsecurity6:3.1.1.RELEASE") @@ -40,13 +43,16 @@ dependencies { implementation("org.webjars:bootstrap:5.2.3") implementation("nz.net.ultraq.thymeleaf:thymeleaf-layout-dialect:3.3.0") // Database - runtimeOnly("org.postgresql:postgresql:42.5.4") + runtimeOnly("org.postgresql:postgresql:42.5.5") implementation("io.hypersistence:hypersistence-utils-hibernate-60:3.2.0") implementation("org.liquibase:liquibase-core:4.26.0") // Utils 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") + implementation("org.antlr:antlr4-runtime:4.10.1") + implementation("org.mockito:mockito-inline:5.2.0") // Annotation processors annotationProcessor("org.mapstruct:mapstruct-processor:1.5.3.Final") // Testing @@ -55,6 +61,7 @@ dependencies { testImplementation("org.testcontainers:junit-jupiter") testImplementation("org.testcontainers:postgresql") testImplementation("com.github.database-rider:rider-spring:1.36.0") + testImplementation("org.wiremock:wiremock-standalone:3.13.1") testRuntimeOnly("org.junit.platform:junit-platform-launcher") testAnnotationProcessor("org.mapstruct:mapstruct-processor:1.5.3.Final") } diff --git a/src/main/java/io/hexlet/typoreporter/HexletTypoReporter.java b/src/main/java/io/hexlet/typoreporter/HexletTypoReporter.java index d9419813..5fc4a7d1 100644 --- a/src/main/java/io/hexlet/typoreporter/HexletTypoReporter.java +++ b/src/main/java/io/hexlet/typoreporter/HexletTypoReporter.java @@ -1,5 +1,7 @@ package io.hexlet.typoreporter; +import io.github.cdimascio.dotenv.Dotenv; +import io.github.cdimascio.dotenv.DotenvEntry; import lombok.extern.slf4j.Slf4j; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @@ -7,6 +9,7 @@ import java.net.InetAddress; import java.net.UnknownHostException; +import java.util.Set; import static java.util.Optional.ofNullable; @@ -15,6 +18,15 @@ public class HexletTypoReporter { public static void main(String[] args) { + Dotenv dotenv = Dotenv.configure() + .ignoreIfMalformed() + .ignoreIfMissing() + .load(); + + Set dotenvInFile = dotenv.entries(Dotenv.Filter.DECLARED_IN_ENV_FILE); + dotenvInFile.forEach(entry -> + System.setProperty(entry.getKey(), entry.getValue())); + final var env = SpringApplication.run(HexletTypoReporter.class, args).getEnvironment(); logApplicationStartup(env); } diff --git a/src/main/java/io/hexlet/typoreporter/config/OAuth2Config.java b/src/main/java/io/hexlet/typoreporter/config/OAuth2Config.java new file mode 100644 index 00000000..921b12ed --- /dev/null +++ b/src/main/java/io/hexlet/typoreporter/config/OAuth2Config.java @@ -0,0 +1,14 @@ +package io.hexlet.typoreporter.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; + +@Configuration +public class OAuth2Config { + + @Bean + public DefaultOAuth2UserService defaultOAuth2UserService() { + return new DefaultOAuth2UserService(); + } +} diff --git a/src/main/java/io/hexlet/typoreporter/config/SecurityConfig.java b/src/main/java/io/hexlet/typoreporter/config/SecurityConfig.java index a93ba4e1..a14b78c4 100644 --- a/src/main/java/io/hexlet/typoreporter/config/SecurityConfig.java +++ b/src/main/java/io/hexlet/typoreporter/config/SecurityConfig.java @@ -1,9 +1,11 @@ package io.hexlet.typoreporter.config; +import io.hexlet.typoreporter.handler.OAuth2AuthenticationFailureHandler; import io.hexlet.typoreporter.handler.exception.ForbiddenDomainException; 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.SocialOAuth2UserService; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; @@ -78,7 +80,8 @@ public SecurityContextRepository securityContextRepository() { @Bean public SecurityFilterChain filterChain(HttpSecurity http, SecurityContextRepository securityContextRepository, - DynamicCorsConfigurationSource dynamicCorsConfigurationSource) throws Exception { + DynamicCorsConfigurationSource dynamicCorsConfigurationSource, + SocialOAuth2UserService socialOAuth2UserService) throws Exception { http.httpBasic(); http.cors(); http.exceptionHandling().accessDeniedHandler(accessDeniedHandler()); @@ -95,6 +98,12 @@ public SecurityFilterChain filterChain(HttpSecurity http, .defaultSuccessUrl("/workspaces") .permitAll() ) + .oauth2Login(oauth -> oauth + .loginPage("/login") + .userInfoEndpoint(userInfo -> + userInfo.userService(socialOAuth2UserService)) + .defaultSuccessUrl("/workspaces", true) + .failureHandler(new OAuth2AuthenticationFailureHandler())) .csrf(csrf -> csrf .ignoringRequestMatchers( new AntPathRequestMatcher("/api/**", POST.name()), diff --git a/src/main/java/io/hexlet/typoreporter/config/WebClientConfig.java b/src/main/java/io/hexlet/typoreporter/config/WebClientConfig.java new file mode 100644 index 00000000..223a3005 --- /dev/null +++ b/src/main/java/io/hexlet/typoreporter/config/WebClientConfig.java @@ -0,0 +1,21 @@ +package io.hexlet.typoreporter.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.web.reactive.function.client.WebClient; + +@Configuration +public class WebClientConfig { + + @Bean + public WebClient githubWebClient(WebClient.Builder builder, + @Value("${spring.security.oauth2.client.provider.github.user-info-uri}") String githubUserURL) { + + return builder.baseUrl(githubUserURL) + .defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE) + .build(); + } +} diff --git a/src/main/java/io/hexlet/typoreporter/domain/account/AuthProvider.java b/src/main/java/io/hexlet/typoreporter/domain/account/AuthProvider.java index 823e38b5..ffe47e99 100644 --- a/src/main/java/io/hexlet/typoreporter/domain/account/AuthProvider.java +++ b/src/main/java/io/hexlet/typoreporter/domain/account/AuthProvider.java @@ -2,6 +2,14 @@ public enum AuthProvider { - EMAIL, GITHUB, GOOGLE + EMAIL, GITHUB, GOOGLE, YANDEX; + public static AuthProvider fromString(String value) { + for (AuthProvider provider : AuthProvider.values()) { + if (provider.name().equalsIgnoreCase(value)) { + return provider; + } + } + return null; + } } diff --git a/src/main/java/io/hexlet/typoreporter/handler/OAuth2AuthenticationFailureHandler.java b/src/main/java/io/hexlet/typoreporter/handler/OAuth2AuthenticationFailureHandler.java new file mode 100644 index 00000000..32b5d83b --- /dev/null +++ b/src/main/java/io/hexlet/typoreporter/handler/OAuth2AuthenticationFailureHandler.java @@ -0,0 +1,28 @@ +package io.hexlet.typoreporter.handler; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.web.authentication.AuthenticationFailureHandler; + +import java.io.IOException; + +public class OAuth2AuthenticationFailureHandler implements AuthenticationFailureHandler { + @Override + public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, + AuthenticationException exception) throws IOException, ServletException { + + String errorMessage = exception.getMessage(); + if (errorMessage == null) { + OAuth2AuthenticationException oAuth2Exception = (OAuth2AuthenticationException) exception; + OAuth2Error error = oAuth2Exception.getError(); + errorMessage = error.getErrorCode(); + } + + request.getSession().setAttribute("errorMessage", errorMessage.trim()); + response.sendRedirect("/login?error"); + } +} 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..33172da7 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 @@ -1,17 +1,16 @@ package io.hexlet.typoreporter.service.dto.account; import lombok.AllArgsConstructor; -import lombok.Getter; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; import java.util.Collection; + @AllArgsConstructor public class CustomUserDetails implements UserDetails { private String email; private String password; - @Getter - private final String nickname; + private String username; private Collection authorities; @Override @@ -48,5 +47,9 @@ public boolean isCredentialsNonExpired() { public boolean isEnabled() { return true; } + + public String getNickname() { + return username; + } } diff --git a/src/main/java/io/hexlet/typoreporter/service/dto/oauth2/CustomOAuth2User.java b/src/main/java/io/hexlet/typoreporter/service/dto/oauth2/CustomOAuth2User.java new file mode 100644 index 00000000..35de4368 --- /dev/null +++ b/src/main/java/io/hexlet/typoreporter/service/dto/oauth2/CustomOAuth2User.java @@ -0,0 +1,22 @@ +package io.hexlet.typoreporter.service.dto.oauth2; + +import lombok.Getter; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.oauth2.core.user.DefaultOAuth2User; + +import java.util.Collection; +import java.util.Map; + +@Getter +public class CustomOAuth2User extends DefaultOAuth2User { + + private final String nickname; + + public CustomOAuth2User(Collection authorities, + Map attributes, + String nameAttributeKey, + String nickname) { + super(authorities, attributes, nameAttributeKey); + this.nickname = nickname; + } +} diff --git a/src/main/java/io/hexlet/typoreporter/service/dto/oauth2/OAuth2UserInfo.java b/src/main/java/io/hexlet/typoreporter/service/dto/oauth2/OAuth2UserInfo.java new file mode 100644 index 00000000..153ba241 --- /dev/null +++ b/src/main/java/io/hexlet/typoreporter/service/dto/oauth2/OAuth2UserInfo.java @@ -0,0 +1,15 @@ +package io.hexlet.typoreporter.service.dto.oauth2; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@NoArgsConstructor +@Getter +@Setter +public class OAuth2UserInfo { + private String email; + private String nickname; + private String firstName; + private String lastName; +} diff --git a/src/main/java/io/hexlet/typoreporter/service/mapper/AccountMapper.java b/src/main/java/io/hexlet/typoreporter/service/mapper/AccountMapper.java index 7091023d..4ee18408 100644 --- a/src/main/java/io/hexlet/typoreporter/service/mapper/AccountMapper.java +++ b/src/main/java/io/hexlet/typoreporter/service/mapper/AccountMapper.java @@ -1,10 +1,12 @@ package io.hexlet.typoreporter.service.mapper; import io.hexlet.typoreporter.domain.account.Account; +import io.hexlet.typoreporter.domain.account.AuthProvider; import io.hexlet.typoreporter.service.account.signup.SignupAccount; import io.hexlet.typoreporter.service.dto.account.InfoAccount; import io.hexlet.typoreporter.service.dto.account.UpdateProfile; import org.mapstruct.Mapper; +import org.mapstruct.Mapping; import org.mapstruct.MappingTarget; @Mapper @@ -14,7 +16,27 @@ public interface AccountMapper { UpdateProfile toUpdateProfile(Account source); + @Mapping(target = "id", ignore = true) + @Mapping(target = "externalOpenId", ignore = true) + @Mapping(target = "workspaceRoles", ignore = true) + @Mapping(target = "typos", ignore = true) + @Mapping(target = "removeTypo", ignore = true) + @Mapping(target = "password", ignore = true) + @Mapping(target = "authProvider", ignore = true) Account toAccount(UpdateProfile source, @MappingTarget Account account); + @Mapping(target = "id", ignore = true) + @Mapping(target = "externalOpenId", ignore = true) + @Mapping(target = "workspaceRoles", ignore = true) + @Mapping(target = "typos", ignore = true) + @Mapping(target = "removeTypo", ignore = true) // Игнорирование метода + @Mapping(target = "authProvider", expression = "java(mapAuthProvider(source.authProvider()))") Account toAccount(SignupAccount source); + + default AuthProvider mapAuthProvider(String authProvider) { + if (authProvider == null) { + return null; + } + return AuthProvider.valueOf(authProvider.toUpperCase()); + } } diff --git a/src/main/java/io/hexlet/typoreporter/service/mapper/TypoMapper.java b/src/main/java/io/hexlet/typoreporter/service/mapper/TypoMapper.java index 072f22d1..8979a538 100644 --- a/src/main/java/io/hexlet/typoreporter/service/mapper/TypoMapper.java +++ b/src/main/java/io/hexlet/typoreporter/service/mapper/TypoMapper.java @@ -17,6 +17,10 @@ public interface TypoMapper { PrettyTime PRETTY_TIME = new PrettyTime(); + @Mapping(target = "id", ignore = true) + @Mapping(target = "typoStatus", ignore = true) + @Mapping(target = "account", ignore = true) + @Mapping(target = "workspace", ignore = true) Typo toTypo(TypoReport source); @Mapping(target = "modifiedDateAgo", source = "modifiedDate", qualifiedByName = "mapToPrettyDateAgo") diff --git a/src/main/java/io/hexlet/typoreporter/service/mapper/WorkspaceMapper.java b/src/main/java/io/hexlet/typoreporter/service/mapper/WorkspaceMapper.java index 94f07bd2..9705b060 100644 --- a/src/main/java/io/hexlet/typoreporter/service/mapper/WorkspaceMapper.java +++ b/src/main/java/io/hexlet/typoreporter/service/mapper/WorkspaceMapper.java @@ -16,6 +16,13 @@ public interface WorkspaceMapper { PrettyTime PRETTY_TIME = new PrettyTime(); + @Mapping(target = "workspaceSettings", ignore = true) + @Mapping(target = "workspaceRoles", ignore = true) + @Mapping(target = "typos", ignore = true) + @Mapping(target = "removeTypo", ignore = true) + @Mapping(target = "removeAllowedUrl", ignore = true) + @Mapping(target = "id", ignore = true) + @Mapping(target = "allowedUrls", ignore = true) Workspace toWorkspace(CreateWorkspace source); @Mapping(target = "createdDateAgo", source = "createdDate", qualifiedByName = "mapToPrettyDateAgo") diff --git a/src/main/java/io/hexlet/typoreporter/service/mapper/oauth2/GithubOAuth2UserInfoMapper.java b/src/main/java/io/hexlet/typoreporter/service/mapper/oauth2/GithubOAuth2UserInfoMapper.java new file mode 100644 index 00000000..74b90c26 --- /dev/null +++ b/src/main/java/io/hexlet/typoreporter/service/mapper/oauth2/GithubOAuth2UserInfoMapper.java @@ -0,0 +1,36 @@ +package io.hexlet.typoreporter.service.mapper.oauth2; + +import io.hexlet.typoreporter.service.dto.oauth2.OAuth2UserInfo; +import org.mapstruct.AfterMapping; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.MappingConstants; +import org.mapstruct.MappingTarget; + +import java.util.Map; + +@Mapper(componentModel = MappingConstants.ComponentModel.SPRING) +public abstract class GithubOAuth2UserInfoMapper { + + @Mapping(target = "email", expression = "java((String) attributes.get(\"email\"))") + @Mapping(target = "nickname", expression = "java((String) attributes.get(\"login\"))") + @Mapping(target = "firstName", ignore = true) + @Mapping(target = "lastName", ignore = true) + public abstract OAuth2UserInfo toUserInfo(Map attributes); + + @AfterMapping + protected void setName(Map attributes, + @MappingTarget OAuth2UserInfo userInfo) { + String[] parts; + Object fullName = attributes.get("name"); + if (fullName == null || fullName.toString().isBlank()) { + parts = new String[] {"", ""}; + } + parts = fullName.toString().trim().split(" "); + if (parts.length == 1) { + parts = new String[]{parts[0], ""}; + } + userInfo.setFirstName(parts[0]); + userInfo.setLastName(parts[1]); + } +} diff --git a/src/main/java/io/hexlet/typoreporter/service/mapper/oauth2/YandexOAuth2UserInfoMapper.java b/src/main/java/io/hexlet/typoreporter/service/mapper/oauth2/YandexOAuth2UserInfoMapper.java new file mode 100644 index 00000000..4f4d427a --- /dev/null +++ b/src/main/java/io/hexlet/typoreporter/service/mapper/oauth2/YandexOAuth2UserInfoMapper.java @@ -0,0 +1,17 @@ +package io.hexlet.typoreporter.service.mapper.oauth2; + +import io.hexlet.typoreporter.service.dto.oauth2.OAuth2UserInfo; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; + +import java.util.Map; + +@Mapper(componentModel = "spring") +public abstract class YandexOAuth2UserInfoMapper { + + @Mapping(target = "email", expression = "java((String) attributes.get(\"default_email\"))") + @Mapping(target = "nickname", expression = "java((String) attributes.get(\"login\"))") + @Mapping(target = "firstName", expression = "java((String) attributes.get(\"first_name\"))") + @Mapping(target = "lastName", expression = "java((String) attributes.get(\"last_name\"))") + public abstract OAuth2UserInfo toUserInfo(Map attributes); +} diff --git a/src/main/java/io/hexlet/typoreporter/service/oauth2/OAuth2UserInfoMapperService.java b/src/main/java/io/hexlet/typoreporter/service/oauth2/OAuth2UserInfoMapperService.java new file mode 100644 index 00000000..b677a929 --- /dev/null +++ b/src/main/java/io/hexlet/typoreporter/service/oauth2/OAuth2UserInfoMapperService.java @@ -0,0 +1,26 @@ +package io.hexlet.typoreporter.service.oauth2; + +import io.hexlet.typoreporter.domain.account.AuthProvider; +import io.hexlet.typoreporter.service.dto.oauth2.OAuth2UserInfo; +import io.hexlet.typoreporter.service.mapper.oauth2.GithubOAuth2UserInfoMapper; +import io.hexlet.typoreporter.service.mapper.oauth2.YandexOAuth2UserInfoMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.Map; + +@Service +@RequiredArgsConstructor +public class OAuth2UserInfoMapperService { + + private final GithubOAuth2UserInfoMapper githubUserInfoMapper; + private final YandexOAuth2UserInfoMapper yandexUserInfoMapper; + + public OAuth2UserInfo getUserInfo(Map attributes, AuthProvider authProvider) { + return switch (authProvider) { + case GITHUB -> githubUserInfoMapper.toUserInfo(attributes); + case YANDEX -> yandexUserInfoMapper.toUserInfo(attributes); + default -> throw new IllegalArgumentException("Unsupported OAuth2 provider: " + authProvider.name()); + }; + } +} diff --git a/src/main/java/io/hexlet/typoreporter/service/oauth2/SocialOAuth2UserService.java b/src/main/java/io/hexlet/typoreporter/service/oauth2/SocialOAuth2UserService.java new file mode 100644 index 00000000..93f8917d --- /dev/null +++ b/src/main/java/io/hexlet/typoreporter/service/oauth2/SocialOAuth2UserService.java @@ -0,0 +1,121 @@ +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 io.hexlet.typoreporter.service.dto.oauth2.CustomOAuth2User; +import io.hexlet.typoreporter.service.dto.oauth2.OAuth2UserInfo; +import io.hexlet.typoreporter.utils.TextUtils; +import lombok.RequiredArgsConstructor; +import org.springframework.core.ParameterizedTypeReference; +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.OAuth2Error; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.client.WebClientResponseException; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Service +@RequiredArgsConstructor +public class SocialOAuth2UserService implements OAuth2UserService { + + private final DefaultOAuth2UserService defaultUserService; + private final OAuth2UserInfoMapperService userInfoMapperService; + private final AccountService accountService; + private final WebClient githubWebClient; + + public static final String NAME_ATTRIBUTE_KEY = "email"; + + @Override + public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { + + AuthProvider authProvider = resolveProvider(userRequest); + OAuth2User oAuth2User = defaultUserService.loadUser(userRequest); + Map attributes = new HashMap<>(oAuth2User.getAttributes()); + + OAuth2UserInfo userInfo = userInfoMapperService.getUserInfo(attributes, authProvider); + String accessTokenValue = userRequest.getAccessToken().getTokenValue(); + String email = resolveEmail(userInfo, accessTokenValue, authProvider); + createNewAccountIfNotExists(userInfo, email, accessTokenValue, authProvider); + + attributes.put(NAME_ATTRIBUTE_KEY, email); + + return new CustomOAuth2User( + oAuth2User.getAuthorities(), + attributes, + NAME_ATTRIBUTE_KEY, + userInfo.getNickname() + ); + } + + private AuthProvider resolveProvider(OAuth2UserRequest userRequest) { + String oAuth2Provider = userRequest.getClientRegistration().getRegistrationId(); + AuthProvider authProvider = AuthProvider.fromString(oAuth2Provider); + if (authProvider == null) { + throw new OAuth2AuthenticationException( + new OAuth2Error("unsupported_provider", "Unsupported provider: " + oAuth2Provider, null)); + } + return authProvider; + } + + private String resolveEmail(OAuth2UserInfo userInfo, String accessTokenValue, + AuthProvider authProvider) { + String email = userInfo.getEmail(); + if (email == null && authProvider == AuthProvider.GITHUB) { + email = getEmailFromGithub(accessTokenValue); + } + if (email == null) { + throw new OAuth2AuthenticationException(new OAuth2Error("email_not_received", + "Email from provider " + authProvider.name() + " not received", null)); + } + return TextUtils.toLowerCaseData(email); + } + + private String getEmailFromGithub(String accessTokenValue) { + try { + List> emails = githubWebClient.get() + .uri("/emails") + .headers(headers -> headers.setBearerAuth(accessTokenValue)) + .retrieve() + .bodyToMono(new ParameterizedTypeReference>>() { }) + .block(); + + if (emails == null) { + return null; + } + + return emails.stream() + .filter(e -> Boolean.TRUE.equals(e.get("primary"))) + .map(e -> (String) e.get("email")) + .findFirst() + .orElse(null); + } catch (WebClientResponseException e) { + throw new OAuth2AuthenticationException( + new OAuth2Error("failed_retrieve_email", "Failed to retrieve email from GitHub", null)); + } + } + + @Transactional + private void createNewAccountIfNotExists(OAuth2UserInfo userInfo, String email, + String accountTokenValue, AuthProvider authProvider) { + if (!accountService.existsByEmail(email)) { + var newAccount = new SignupAccount( + userInfo.getNickname(), + email, + accountTokenValue, + userInfo.getFirstName(), + userInfo.getLastName(), + authProvider.name() + ); + accountService.signup(newAccount); + } + } +} 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-dev.yml b/src/main/resources/config/application-dev.yml index af7e34cf..be74d121 100644 --- a/src/main/resources/config/application-dev.yml +++ b/src/main/resources/config/application-dev.yml @@ -26,6 +26,13 @@ logging: web: DEBUG org.hibernate.type.descriptor.sql: TRACE org.springframework.security: TRACE + org: + springframework: + web: + client: + RestTemplate: DEBUG + security: + oauth2: TRACE io.hexlet.typoreporter: DEBUG server: error: diff --git a/src/main/resources/config/application.yml b/src/main/resources/config/application.yml index a4410433..0aed0971 100644 --- a/src/main/resources/config/application.yml +++ b/src/main/resources/config/application.yml @@ -1,6 +1,38 @@ spring: application: name: hexletTypoReporter + profiles: + active: dev + security: + oauth2: + client: + 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 + yandex: + authorization-uri: https://oauth.yandex.ru/authorize + token-uri: https://oauth.yandex.ru/token + user-info-uri: https://login.yandex.ru/info + user-name-attribute: default_email + 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 + yandex: + client-id: ${YANDEX_CLIENT_ID} + client-secret: ${YANDEX_CLIENT_SECRET} + redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}" + authorization-grant-type: authorization_code + scope: login:email, login:info + client-name: Yandex jpa: open-in-view: false hibernate: diff --git a/src/main/resources/db/changelog/changesets/2025/06/2025-06-31-add-yandex-to-auth-provider.xml b/src/main/resources/db/changelog/changesets/2025/06/2025-06-31-add-yandex-to-auth-provider.xml new file mode 100644 index 00000000..bc4a8409 --- /dev/null +++ b/src/main/resources/db/changelog/changesets/2025/06/2025-06-31-add-yandex-to-auth-provider.xml @@ -0,0 +1,10 @@ + + + + ALTER TYPE auth_provider ADD VALUE 'YANDEX'; + + diff --git a/src/main/resources/db/changelog/db.changelog-master.xml b/src/main/resources/db/changelog/db.changelog-master.xml index e68d18d9..a0b76903 100644 --- a/src/main/resources/db/changelog/db.changelog-master.xml +++ b/src/main/resources/db/changelog/db.changelog-master.xml @@ -27,4 +27,6 @@ relativeToChangelogFile="true" /> + diff --git a/src/main/resources/messages_en.properties b/src/main/resources/messages_en.properties index 089e6c11..f472c2f8 100644 --- a/src/main/resources/messages_en.properties +++ b/src/main/resources/messages_en.properties @@ -144,3 +144,9 @@ 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 +link.yandex-create-account=Create Account with Yandex +link.yandex-login=Login with Yandex diff --git a/src/main/resources/messages_ru.properties b/src/main/resources/messages_ru.properties index 308e6e74..3f57c161 100644 --- a/src/main/resources/messages_ru.properties +++ b/src/main/resources/messages_ru.properties @@ -142,3 +142,9 @@ 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 +link.yandex-create-account=Регистрация с помощью Yandex +link.yandex-login=Войти с помощью Yandex diff --git a/src/main/resources/templates/account/signup.html b/src/main/resources/templates/account/signup.html index a5509078..337b0545 100644 --- a/src/main/resources/templates/account/signup.html +++ b/src/main/resources/templates/account/signup.html @@ -50,5 +50,11 @@ +
+
+ + +
+
diff --git a/src/main/resources/templates/login.html b/src/main/resources/templates/login.html index 0cd2413e..e56e98d3 100644 --- a/src/main/resources/templates/login.html +++ b/src/main/resources/templates/login.html @@ -23,6 +23,12 @@ +
+
+ + +
+
diff --git a/src/test/java/io/hexlet/typoreporter/service/oauth2/OAuth2UserInfoMapperServiceTest.java b/src/test/java/io/hexlet/typoreporter/service/oauth2/OAuth2UserInfoMapperServiceTest.java new file mode 100644 index 00000000..a35fc298 --- /dev/null +++ b/src/test/java/io/hexlet/typoreporter/service/oauth2/OAuth2UserInfoMapperServiceTest.java @@ -0,0 +1,61 @@ +package io.hexlet.typoreporter.service.oauth2; + +import io.hexlet.typoreporter.domain.account.AuthProvider; +import io.hexlet.typoreporter.service.dto.oauth2.OAuth2UserInfo; +import io.hexlet.typoreporter.service.mapper.oauth2.GithubOAuth2UserInfoMapper; +import io.hexlet.typoreporter.service.mapper.oauth2.YandexOAuth2UserInfoMapper; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Collections; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class OAuth2UserInfoMapperServiceTest { + + @Mock + private GithubOAuth2UserInfoMapper githubUserInfoMapper; + + @Mock + private YandexOAuth2UserInfoMapper yandexUserInfoMapper; + + @Mock + private OAuth2UserInfo userInfo; + + @InjectMocks + private OAuth2UserInfoMapperService mapperService; + + @Test + void testGetUserInfoWhenGithubProvider() { + Map attributes = Collections.emptyMap(); + when(githubUserInfoMapper.toUserInfo(attributes)).thenReturn(userInfo); + + assertThat(mapperService.getUserInfo(attributes, AuthProvider.GITHUB)) + .isInstanceOf(OAuth2UserInfo.class); + } + + @Test + void testGetUserInfoWhenYandexProvider() { + Map attributes = Collections.emptyMap(); + when(yandexUserInfoMapper.toUserInfo(attributes)).thenReturn(userInfo); + + assertThat(mapperService.getUserInfo(attributes, AuthProvider.YANDEX)) + .isInstanceOf(OAuth2UserInfo.class); + } + + @Test + void testGetUserInfoWhenUnsupportedOAuth2Provider() { + Map attributes = Collections.emptyMap(); + + assertThatThrownBy(() -> mapperService.getUserInfo(attributes, AuthProvider.EMAIL)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Unsupported OAuth2 provider: EMAIL"); + } +} diff --git a/src/test/java/io/hexlet/typoreporter/service/oauth2/SocialOAuth2UserServiceTest.java b/src/test/java/io/hexlet/typoreporter/service/oauth2/SocialOAuth2UserServiceTest.java new file mode 100644 index 00000000..8b4c6b99 --- /dev/null +++ b/src/test/java/io/hexlet/typoreporter/service/oauth2/SocialOAuth2UserServiceTest.java @@ -0,0 +1,210 @@ +package io.hexlet.typoreporter.service.oauth2; + +import com.github.tomakehurst.wiremock.client.WireMock; +import com.github.tomakehurst.wiremock.core.WireMockConfiguration; +import com.github.tomakehurst.wiremock.junit5.WireMockExtension; +import com.github.tomakehurst.wiremock.junit5.WireMockTest; +import io.hexlet.typoreporter.domain.account.AuthProvider; +import io.hexlet.typoreporter.service.AccountService; +import io.hexlet.typoreporter.service.account.signup.SignupAccount; +import io.hexlet.typoreporter.service.dto.oauth2.CustomOAuth2User; +import io.hexlet.typoreporter.service.dto.oauth2.OAuth2UserInfo; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.web.reactive.function.client.WebClient; + +import java.time.Instant; +import java.util.Collections; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@WireMockTest +class SocialOAuth2UserServiceTest { + + @Mock + private DefaultOAuth2UserService defaultUserService; + + @Mock + private OAuth2User user; + + @Mock + private OAuth2UserInfoMapperService userInfoMapperService; + + @Mock + private OAuth2UserInfo userInfo; + + @Mock + private AccountService accountService; + + private SocialOAuth2UserService userService; + + @RegisterExtension + static WireMockExtension wireMockExtension = WireMockExtension.newInstance() + .options(WireMockConfiguration.wireMockConfig().dynamicPort()) + .build(); + + @BeforeEach + void setup() { + WebClient githubWebClient = WebClient.builder() + .baseUrl("http://localhost:" + wireMockExtension.getPort()) + .build(); + + userService = new SocialOAuth2UserService( + defaultUserService, + userInfoMapperService, + accountService, + githubWebClient + ); + } + + @Test + void testResolveProviderWhenUnsupportedProvider() { + var request = mockRequest("unknown", "token"); + + assertThatThrownBy(() -> userService.loadUser(request)) + .isInstanceOf(OAuth2AuthenticationException.class) + .hasMessageContaining("Unsupported provider: unknown"); + } + + @Test + void testResolveEmailWhenEmailNotRecievedFromAnyProvider() { + var request = mockRequest("Github", "token"); + + wireMockExtension.stubFor(WireMock.get(urlPathEqualTo("/emails")) + .willReturn(aResponse() + .withHeader("Content-Type", "application/json") + .withBody(""" + [ + {"email": "test@example.com", "primary": false} + ] + """))); + + when(defaultUserService.loadUser(request)).thenReturn(user); + when(user.getAttributes()).thenReturn(Collections.emptyMap()); + when(userInfoMapperService.getUserInfo(Collections.emptyMap(), AuthProvider.GITHUB)).thenReturn(userInfo); + when(userInfo.getEmail()).thenReturn(null); + + assertThatThrownBy(() -> userService.loadUser(request)) + .isInstanceOf(OAuth2AuthenticationException.class) + .hasMessageContaining("Email from provider GITHUB not received"); + } + + @Test + void testResolveEmailWhenEmailFailedRetrieveFromGithubProvider() { + var request = mockRequest("Github", "token"); + + wireMockExtension.stubFor(WireMock.get(urlPathEqualTo("/emails")) + .willReturn(aResponse().withStatus(500))); + + when(defaultUserService.loadUser(request)).thenReturn(user); + when(user.getAttributes()).thenReturn(Collections.emptyMap()); + when(userInfoMapperService.getUserInfo(Collections.emptyMap(), AuthProvider.GITHUB)).thenReturn(userInfo); + when(userInfo.getEmail()).thenReturn(null); + + assertThatThrownBy(() -> userService.loadUser(request)) + .isInstanceOf(OAuth2AuthenticationException.class) + .hasMessageContaining("Failed to retrieve email from GitHub"); + } + + @Test + void testLoadUserWhenUserExists() { + var request = mockRequest("Github", "token"); + + when(defaultUserService.loadUser(request)).thenReturn(user); + when(user.getAttributes()).thenReturn(Collections.emptyMap()); + when(user.getAuthorities()).thenReturn(Collections.emptyList()); + + OAuth2UserInfo ui = new OAuth2UserInfo(); + ui.setEmail("TesT@example.com"); + ui.setNickname("johndoe"); + ui.setFirstName("John"); + ui.setLastName("Doe"); + + when(userInfoMapperService.getUserInfo(Collections.emptyMap(), AuthProvider.GITHUB)).thenReturn(ui); + when(accountService.existsByEmail("test@example.com")).thenReturn(true); + + OAuth2User oAuth2User = userService.loadUser(request); + + assertThat(oAuth2User).isInstanceOf(CustomOAuth2User.class); + verify(accountService).existsByEmail("test@example.com"); + verify(accountService, never()).signup(any()); + } + + @Test + void testLoadUserWhenUserNotExists() { + var request = mockRequest("Github", "token"); + + when(defaultUserService.loadUser(request)).thenReturn(user); + when(user.getAttributes()).thenReturn(Collections.emptyMap()); + when(user.getAuthorities()).thenReturn(Collections.emptyList()); + + OAuth2UserInfo ui = new OAuth2UserInfo(); + ui.setEmail("New-Test@example.com"); + ui.setNickname("johndoe"); + ui.setFirstName("John"); + ui.setLastName("Doe"); + + when(userInfoMapperService.getUserInfo(Collections.emptyMap(), AuthProvider.GITHUB)).thenReturn(ui); + when(accountService.existsByEmail("new-test@example.com")).thenReturn(false); + + OAuth2User user = userService.loadUser(request); + + assertThat(user.getAttributes()).containsEntry("email", "new-test@example.com"); + ArgumentCaptor captor = ArgumentCaptor.forClass(SignupAccount.class); + verify(accountService).signup(captor.capture()); + SignupAccount signup = captor.getValue(); + assertThat(signup.email()).isEqualTo("new-test@example.com"); + + CustomOAuth2User customUser = (CustomOAuth2User) user; + + assertThat(customUser.getName()).isEqualTo("new-test@example.com"); + assertThat(customUser.getNickname()).isEqualTo("johndoe"); + assertThat(customUser.getAuthorities()).isEmpty(); + assertThat(customUser.getAttributes()).containsEntry("email", "new-test@example.com"); + } + + public static OAuth2UserRequest mockRequest(String registrationId, String tokenValue) { + return new OAuth2UserRequest( + mockRegistration(registrationId), + new OAuth2AccessToken( + OAuth2AccessToken.TokenType.BEARER, + tokenValue, + Instant.now().minusSeconds(60), + Instant.now().plusSeconds(3600) + ) + ); + } + + public static ClientRegistration mockRegistration(String registrationId) { + return ClientRegistration.withRegistrationId(registrationId) + .authorizationUri("https://example.com/oauth2/authorize") + .tokenUri("https://example.com/oauth2/token") + .clientId("client-id") + .clientSecret("client-secret") + .redirectUri("{baseUrl}/login/oauth2/code/{registrationId}") + .scope("read:user", "user:email") + .authorizationGrantType(org.springframework.security.oauth2.core.AuthorizationGrantType.AUTHORIZATION_CODE) + .userInfoUri("https://example.com/user") + .userNameAttributeName("id") + .build(); + } +} diff --git a/src/test/java/io/hexlet/typoreporter/web/GithubOAuth2LoginIT.java b/src/test/java/io/hexlet/typoreporter/web/GithubOAuth2LoginIT.java new file mode 100644 index 00000000..2923a183 --- /dev/null +++ b/src/test/java/io/hexlet/typoreporter/web/GithubOAuth2LoginIT.java @@ -0,0 +1,329 @@ +package io.hexlet.typoreporter.web; + +import com.github.database.rider.core.api.configuration.DBUnit; +import com.github.database.rider.spring.api.DBRider; +import com.github.tomakehurst.wiremock.client.WireMock; +import com.github.tomakehurst.wiremock.core.WireMockConfiguration; +import com.github.tomakehurst.wiremock.junit5.WireMockExtension; +import com.github.tomakehurst.wiremock.junit5.WireMockTest; +import io.hexlet.typoreporter.service.AccountService; +import io.hexlet.typoreporter.service.account.signup.SignupAccount; +import io.hexlet.typoreporter.test.DBUnitEnumPostgres; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.mock.web.MockHttpSession; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.context.WebApplicationContext; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.containing; +import static com.github.tomakehurst.wiremock.client.WireMock.equalTo; +import static com.github.database.rider.core.api.configuration.Orthography.LOWERCASE; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; +import static io.hexlet.typoreporter.test.Constraints.POSTGRES_IMAGE; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.startsWith; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.springframework.web.util.UriComponentsBuilder.fromUriString; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +@WireMockTest +@Testcontainers +@Transactional +@DBRider +@DBUnit(caseInsensitiveStrategy = LOWERCASE, dataTypeFactoryClass = DBUnitEnumPostgres.class, cacheConnection = false) +public class GithubOAuth2LoginIT { + + @Autowired + private WebApplicationContext wac; + + @Autowired + private AccountService accountService; + + @LocalServerPort + private int port; + + private MockMvc mockMvc; + + private final String code = "fake-auth-code"; + private final String accessToken = "fake-access-token"; + + @RegisterExtension + static WireMockExtension wireMockExtension = WireMockExtension.newInstance() + .options(WireMockConfiguration.wireMockConfig().dynamicPort()) + .build(); + + @Container + static PostgreSQLContainer postgreSQLContainer = new PostgreSQLContainer<>(POSTGRES_IMAGE) + .withPassword("inmemory") + .withUsername("inmemory"); + + + @DynamicPropertySource + static void datasourceProperties(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", postgreSQLContainer::getJdbcUrl); + registry.add("spring.datasource.password", postgreSQLContainer::getPassword); + registry.add("spring.datasource.username", postgreSQLContainer::getUsername); + + String wireMockPort = String.valueOf(wireMockExtension.getPort()); + registry.add("spring.security.oauth2.client.provider.github.authorization-uri", + () -> "http://localhost:" + wireMockPort + "/login/oauth/authorize"); + registry.add("spring.security.oauth2.client.provider.github.token-uri", + () -> "http://localhost:" + wireMockPort + "/login/oauth/access_token"); + registry.add("spring.security.oauth2.client.provider.github.user-info-uri", + () -> "http://localhost:" + wireMockPort + "/user"); + } + + @BeforeEach + void setUpMockMvc() { + this.mockMvc = MockMvcBuilders + .webAppContextSetup(wac) + .defaultRequest(get("/").header("Host", "localhost:" + port)) + .apply(springSecurity()) + .build(); + } + + @Test + void testGithubOAuth2AuthenticationNewUser() throws Exception { + + // Securing GET /oauth2/authorization/github + var result = mockMvc.perform(get("/oauth2/authorization/github")) + .andExpect(status().is3xxRedirection()) + .andExpect(header().string("Location", startsWith(wireMockExtension.baseUrl() + "/login/oauth/authorize"))) + .andExpect(header().string("Location", containsString("client_id=fake-client-id"))) + .andReturn(); + + String location = result.getResponse().getHeader("Location"); + assertNotNull(location); + + String encodedState = fromUriString(location).build().getQueryParams().getFirst("state"); + assertNotNull(encodedState); + String state = URLDecoder.decode(encodedState, StandardCharsets.UTF_8); + + MockHttpSession session = (MockHttpSession) result.getRequest().getSession(); + assertNotNull(session); + + // HTTP POST https://github.com/login/oauth/access_token + wireMockExtension.stubFor(WireMock.post(urlPathEqualTo("/login/oauth/access_token")) + .withHeader("Content-Type", containing("application/x-www-form-urlencoded")) + .willReturn(aResponse().withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(""" + { + "access_token": "fake-access-token", + "token_type": "bearer" + } + """))); + + // HTTP GET https://api.github.com/user + wireMockExtension.stubFor(WireMock.get(urlPathEqualTo("/user")) + .withHeader("Authorization", equalTo("Bearer " + accessToken)) + .willReturn(aResponse().withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(""" + { + "login": "johndoe", + "name": "John Doe", + "email": null + } + """))); + + // HTTP GET https://api.github.com/user/emails + wireMockExtension.stubFor(WireMock.get(urlPathEqualTo("/user/emails")) + .withHeader("Authorization", equalTo("Bearer " + accessToken)) + .willReturn(aResponse().withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(""" + [ + { + "email": "JohnDoe@example.com", + "primary": true + } + ] + """))); + + assertFalse(accountService.existsByEmail("johndoe@example.com")); + + // Securing GET /login/oauth2/code/github?code=fake-code&state=some-state + mockMvc.perform(get("/login/oauth2/code/github?code=" + code + "&state=" + state) + .session(session)) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/workspaces")); + + assertTrue(accountService.existsByEmail("johndoe@example.com")); + } + + + @Test + void testGithubOAuth2AuthenticationExistingUser() throws Exception { + + var existingAccount = new SignupAccount( + "johndoe", + "johndoe@example.com", + "fake-password", + "John", + "Doe", + "email" + ); + accountService.signup(existingAccount); + + // Securing GET /oauth2/authorization/github + var result = mockMvc.perform(get("/oauth2/authorization/github")) + .andExpect(status().is3xxRedirection()) + .andExpect(header().string("Location", startsWith(wireMockExtension.baseUrl() + "/login/oauth/authorize"))) + .andExpect(header().string("Location", containsString("client_id=fake-client-id"))) + .andReturn(); + + String location = result.getResponse().getHeader("Location"); + assertNotNull(location); + + String encodedState = fromUriString(location).build().getQueryParams().getFirst("state"); + assertNotNull(encodedState); + String state = URLDecoder.decode(encodedState, StandardCharsets.UTF_8); + + MockHttpSession session = (MockHttpSession) result.getRequest().getSession(); + assertNotNull(session); + + // HTTP POST https://github.com/login/oauth/access_token + wireMockExtension.stubFor(WireMock.post(urlPathEqualTo("/login/oauth/access_token")) + .withHeader("Content-Type", containing("application/x-www-form-urlencoded")) + .willReturn(aResponse().withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(""" + { + "access_token": "fake-access-token", + "token_type": "bearer" + } + """))); + + // HTTP GET https://api.github.com/user + wireMockExtension.stubFor(WireMock.get(urlPathEqualTo("/user")) + .withHeader("Authorization", equalTo("Bearer " + accessToken)) + .willReturn(aResponse().withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(""" + { + "login": "johndoe", + "name": "John Doe", + "email": "johndoe@example.com" + } + """))); + + // Securing GET /login/oauth2/code/github?code=fake-code&state=some-state + mockMvc.perform(get("/login/oauth2/code/github?code=" + code + "&state=" + state) + .session(session)) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/workspaces")); + + assertEquals(1, accountService.findAll().size()); + } + + @Test + void testGithubOAuth2AuthenticationInvalidState() throws Exception { + + // Securing GET /oauth2/authorization/github + var result = mockMvc.perform(get("/oauth2/authorization/github")) + .andExpect(status().is3xxRedirection()) + .andExpect(header().string("Location", startsWith(wireMockExtension.baseUrl() + "/login/oauth/authorize"))) + .andExpect(header().string("Location", containsString("client_id=fake-client-id"))) + .andReturn(); + + MockHttpSession session = (MockHttpSession) result.getRequest().getSession(); + assertNotNull(session); + + String invalidState = "invalid-state"; + + // Securing GET /login/oauth2/code/github?code=fake-code&state=some-state + mockMvc.perform(get("/login/oauth2/code/github?code=" + code + "&state=" + invalidState) + .session(session)) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/login?error")); + + assertEquals("[authorization_request_not_found]", session.getAttribute("errorMessage")); + } + + @Test + void testGithubOAuth2AuthenticationNoEmailFromProvider() throws Exception { + + // Securing GET /oauth2/authorization/github + var result = mockMvc.perform(get("/oauth2/authorization/github")) + .andExpect(status().is3xxRedirection()) + .andExpect(header().string("Location", startsWith(wireMockExtension.baseUrl() + "/login/oauth/authorize"))) + .andExpect(header().string("Location", containsString("client_id=fake-client-id"))) + .andReturn(); + + String location = result.getResponse().getHeader("Location"); + assertNotNull(location); + + String encodedState = fromUriString(location).build().getQueryParams().getFirst("state"); + assertNotNull(encodedState); + String state = URLDecoder.decode(encodedState, StandardCharsets.UTF_8); + + MockHttpSession session = (MockHttpSession) result.getRequest().getSession(); + assertNotNull(session); + + // HTTP POST https://github.com/login/oauth/access_token + wireMockExtension.stubFor(WireMock.post(urlPathEqualTo("/login/oauth/access_token")) + .withHeader("Content-Type", containing("application/x-www-form-urlencoded")) + .willReturn(aResponse().withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(""" + { + "access_token": "fake-access-token", + "token_type": "bearer" + } + """))); + + // HTTP GET https://api.github.com/user + wireMockExtension.stubFor(WireMock.get(urlPathEqualTo("/user")) + .withHeader("Authorization", equalTo("Bearer " + accessToken)) + .willReturn(aResponse().withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(""" + { + "login": "johndoe", + "name": "John Doe", + "email": null + } + """))); + + // HTTP GET https://api.github.com/user/emails + wireMockExtension.stubFor(WireMock.get(urlPathEqualTo("/user/emails")) + .withHeader("Authorization", equalTo("Bearer " + accessToken)) + .willReturn(aResponse().withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody("[]"))); + + // Securing GET /login/oauth2/code/github?code=fake-code&state=some-state + mockMvc.perform(get("/login/oauth2/code/github?code=" + code + "&state=" + state) + .session(session)) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/login?error")); + + assertEquals("Email from provider GITHUB not received", session.getAttribute("errorMessage")); + } +} diff --git a/src/test/java/io/hexlet/typoreporter/web/LoginIT.java b/src/test/java/io/hexlet/typoreporter/web/LoginIT.java index 1e9cdf85..c78370fb 100644 --- a/src/test/java/io/hexlet/typoreporter/web/LoginIT.java +++ b/src/test/java/io/hexlet/typoreporter/web/LoginIT.java @@ -56,7 +56,8 @@ static void datasourceProperties(DynamicPropertyRegistry registry) { "model_upper_case", EMAIL_UPPER_CASE, "password", "password", - "firstName", "lastName"); + "firstName", "lastName", + "EMAIL"); @Test void loginByEmailInAnyCaseSuccess() throws Exception { diff --git a/src/test/java/io/hexlet/typoreporter/web/SignupControllerIT.java b/src/test/java/io/hexlet/typoreporter/web/SignupControllerIT.java index 39f61c38..68d7f70c 100644 --- a/src/test/java/io/hexlet/typoreporter/web/SignupControllerIT.java +++ b/src/test/java/io/hexlet/typoreporter/web/SignupControllerIT.java @@ -60,13 +60,15 @@ static void datasourceProperties(DynamicPropertyRegistry registry) { "model_upper_case", EMAIL_UPPER_CASE, "password", "password", - "firstName", "lastName"); + "firstName", "lastName", + "EMAIL"); private final SignupAccountModel anotherModelWithSameButLowerCaseEmail = new SignupAccountModel( "model_lower_case", EMAIL_LOWER_CASE, "another_password", "another_password", - "another_firstName", "another_lastName"); + "another_firstName", "another_lastName", + "EMAIL"); private static ResourceBundleMessageSource source; diff --git a/src/test/java/io/hexlet/typoreporter/web/YandexOAuth2LoginIT.java b/src/test/java/io/hexlet/typoreporter/web/YandexOAuth2LoginIT.java new file mode 100644 index 00000000..02117cf7 --- /dev/null +++ b/src/test/java/io/hexlet/typoreporter/web/YandexOAuth2LoginIT.java @@ -0,0 +1,158 @@ +package io.hexlet.typoreporter.web; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.equalTo; +import static com.github.tomakehurst.wiremock.client.WireMock.okJson; +import static com.github.tomakehurst.wiremock.client.WireMock.post; +import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; +import static io.hexlet.typoreporter.test.Constraints.POSTGRES_IMAGE; +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames.REGISTRATION_ID; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.github.tomakehurst.wiremock.WireMockServer; +import com.github.tomakehurst.wiremock.client.WireMock; +import com.github.tomakehurst.wiremock.core.WireMockConfiguration; +import io.hexlet.typoreporter.domain.account.AuthProvider; +import io.hexlet.typoreporter.domain.account.Account; +import io.hexlet.typoreporter.repository.AccountRepository; +import jakarta.transaction.Transactional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.mock.web.MockHttpSession; +import org.springframework.security.oauth2.client.web.HttpSessionOAuth2AuthorizationRequestRepository; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.web.servlet.MockMvc; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import java.util.HashMap; +import java.util.Map; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK) +@AutoConfigureMockMvc +@Testcontainers +@Transactional +class YandexOAuth2LoginIT { + + @Container + static PostgreSQLContainer postgreSQLContainer = new PostgreSQLContainer<>(POSTGRES_IMAGE) + .withPassword("inmemory") + .withUsername("inmemory"); + + @Autowired + MockMvc mockMvc; + + @Autowired + AccountRepository accountRepository; + + static WireMockServer wireMockServer; + + @DynamicPropertySource + static void property(DynamicPropertyRegistry registry) { + if (wireMockServer == null) { + wireMockServer = new WireMockServer(WireMockConfiguration.options().dynamicPort()); + wireMockServer.start(); + } + String base = "http://localhost:" + wireMockServer.port(); + + registry.add("spring.datasource.url", postgreSQLContainer::getJdbcUrl); + registry.add("spring.datasource.username", postgreSQLContainer::getUsername); + registry.add("spring.datasource.password", postgreSQLContainer::getPassword); + + registry.add("spring.security.oauth2.client.registration.yandex.client-id", + () -> "test-client-id"); + registry.add("spring.security.oauth2.client.registration.yandex.client-secret", + () -> "test-client-secret"); + registry.add("spring.security.oauth2.client.registration.yandex.provider", + () -> "yandex"); + registry.add("spring.security.oauth2.client.registration.yandex.redirect-uri", + () -> "{baseUrl}/login/oauth2/code/{registrationId}"); + registry.add("spring.security.oauth2.client.registration.yandex.authorization-grant-type", + () -> "authorization_code"); + registry.add("spring.security.oauth2.client.registration.yandex.scope", + () -> "email"); + registry.add("spring.security.oauth2.client.registration.yandex.user-name-attribute", + () -> "default_email"); + registry.add("spring.security.oauth2.client.provider.yandex.authorization-uri", + () -> base + "/oauth/authorize"); + registry.add("spring.security.oauth2.client.provider.yandex.token-uri", + () -> base + "/oauth/token"); + registry.add("spring.security.oauth2.client.provider.yandex.user-info-uri", + () -> base + "/userinfo"); + } + + @BeforeEach + void setup() { + wireMockServer.resetAll(); + + wireMockServer.stubFor(post("/oauth/token") + .willReturn(okJson("{ \"access_token\":\"mock-token\",\"token_type\":\"bearer\" }"))); + + wireMockServer.stubFor(WireMock.get(urlEqualTo("/userinfo")) + .withHeader("Authorization", equalTo("Bearer mock-token")) + .willReturn(okJson(""" + { + "id":"yandex-id", + "default_email":"test@yandex.ru", + "login":"yandex_user" + } + """))); + } + + @Test + void yandexLogin() throws Exception { + Map attributes = new HashMap<>(); + attributes.put(REGISTRATION_ID, "yandex"); + + OAuth2AuthorizationRequest authRequest = OAuth2AuthorizationRequest + .authorizationCode() + .clientId("test-client-id") + .authorizationUri("http://localhost:" + wireMockServer.port() + "/oauth/authorize") + .redirectUri("http://localhost/login/oauth2/code/yandex") + .state("myspecialstate") + .attributes(attributes) + .build(); + + String key = HttpSessionOAuth2AuthorizationRequestRepository + .class.getName() + ".AUTHORIZATION_REQUEST"; + MockHttpSession session = new MockHttpSession(); + session.setAttribute(key, authRequest); + + mockMvc.perform(get("/login/oauth2/code/yandex") + .session(session) + .param("code", "fake-code") + .param("state", "myspecialstate") + .with(csrf())) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/workspaces")); + + Account account = accountRepository + .findAccountByEmail("test@yandex.ru") + .orElseThrow(); + assertThat(account.getAuthProvider()).isEqualTo(AuthProvider.YANDEX); + assertThat(account.getUsername()).isEqualTo("yandex_user"); + } + + @Test + void yandexBadLogin() throws Exception { + wireMockServer.stubFor(post("/oauth/token").willReturn(aResponse().withStatus(400))); + + mockMvc.perform(get("/login/oauth2/code/yandex") + .param("code", "bad") + .with(csrf())) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/login?error")); + + assertThat(accountRepository.count()).isZero(); + } +} diff --git a/src/test/resources/config/application.yml b/src/test/resources/config/application.yml index 90b0b4a0..6321b424 100644 --- a/src/test/resources/config/application.yml +++ b/src/test/resources/config/application.yml @@ -1,6 +1,30 @@ spring: application: name: hexletTypoReporter + security: + oauth2: + client: + registration: + github: + client-id: fake-client-id + client-secret: fake-client-secret + redirect-uri: "{baseUrl}/login/oauth2/code/github" + yandex: + client-id: test-client-id + client-secret: test-client-secret + redirect-uri: http://localhost:8080/login/oauth2/code/yandex + authorization-grant-type: authorization_code + scope: login:email + client-name: Yandex + provider: + github: + user-info-uri: https://api.github.com/user + user-name-attribute: login + yandex: + authorization-uri: https://oauth.yandex.ru/authorize + token-uri: https://oauth.yandex.ru/token + user-info-uri: https://login.yandex.ru/info + user-name-attribute: default_email jpa: open-in-view: true show-sql: true @@ -17,12 +41,16 @@ spring: enabled: true problemdetails: enabled: true - logging: level: root: INFO web: DEBUG - org.hibernate.type.descriptor.sql: TRACE + org: + hibernate.type.descriptor.sql: TRACE + springframework: + security: DEBUG + web: + client: DEBUG io.hexlet.typoreporter: DEBUG # Bug in database rider, a lot of log 'warning' com.github.database.rider.core.api.dataset.ScriptableTable: ERROR diff --git a/system.properties b/system.properties index f4858733..5a9b50d8 100644 --- a/system.properties +++ b/system.properties @@ -1 +1 @@ -java.runtime.version=19 +java.runtime.version=21