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 extends GrantedAuthority> 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 extends GrantedAuthority> 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