diff --git a/.github/workflows/pullrequest.yml b/.github/workflows/pullrequest.yml new file mode 100644 index 0000000..9330a2f --- /dev/null +++ b/.github/workflows/pullrequest.yml @@ -0,0 +1,16 @@ +name: Java CI + +on: [push] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - name: Set up JDK 11 + uses: actions/setup-java@v1 + with: + java-version: 11 + - name: Build with Maven + run: mvn -B package --file pom.xml diff --git a/checkstyle.xml b/checkstyle.xml new file mode 100644 index 0000000..4f94986 --- /dev/null +++ b/checkstyle.xml @@ -0,0 +1,156 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client-web/.gitignore b/client-web/.gitignore new file mode 100644 index 0000000..6b6890a --- /dev/null +++ b/client-web/.gitignore @@ -0,0 +1,23 @@ +# dependencies +/node_modules +/.pnp +.pnp.js +package-lock.json + +# testing +/coverage + +# production +/build +/node + +# misc +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local + +npm-debug.log* +yarn-debug.log* +yarn-error.log* diff --git a/client-web/package.json b/client-web/package.json new file mode 100644 index 0000000..38ade89 --- /dev/null +++ b/client-web/package.json @@ -0,0 +1,48 @@ +{ + "name": "front-end", + "version": "0.1.0", + "private": true, + "dependencies": { + "@material-ui/core": "^4.11.0", + "@testing-library/jest-dom": "^5.11.5", + "@testing-library/react": "^11.1.2", + "@testing-library/user-event": "^12.2.2", + "bootstrap": "^4.5.3", + "node-sass": "^5.0.0", + "react": "^17.0.1", + "react-dom": "^17.0.1", + "react-redux": "^7.2.2", + "react-router-dom": "^5.2.0", + "react-scripts": "4.0.0", + "redux": "^4.0.5", + "web-vitals": "^0.2.4" + }, + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test", + "eject": "react-scripts eject", + "lint": "tslint src/*", + "build:tslint": "npm run lint && npm run build" + }, + "eslintConfig": { + "extends": [ + "react-app", + "react-app/jest" + ] + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version" + ] + }, + "devDependencies": { + "tslint": "^6.1.3", + "typescript": "~3.7.2" + } +} diff --git a/client-web/public/index.html b/client-web/public/index.html new file mode 100644 index 0000000..7ceb623 --- /dev/null +++ b/client-web/public/index.html @@ -0,0 +1,12 @@ + + + + + + social-network + + + +
+ + diff --git a/client-web/src/App.js b/client-web/src/App.js new file mode 100644 index 0000000..401ee13 --- /dev/null +++ b/client-web/src/App.js @@ -0,0 +1,10 @@ +import React from "react" +import {Login} from "./pages/Login/Login"; + +function App() { + return ( + + ); +} + +export default App; diff --git a/client-web/src/index.js b/client-web/src/index.js new file mode 100644 index 0000000..7384bc7 --- /dev/null +++ b/client-web/src/index.js @@ -0,0 +1,10 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import App from './App'; + +ReactDOM.render( + + + , + document.getElementById('root') +); diff --git a/client-web/src/pages/Login/Login.tsx b/client-web/src/pages/Login/Login.tsx new file mode 100644 index 0000000..ec29278 --- /dev/null +++ b/client-web/src/pages/Login/Login.tsx @@ -0,0 +1,4 @@ +import * as React from 'react'; + +export const Login = () =>

Login page

+ diff --git a/client-web/src/react-app-env.d.ts b/client-web/src/react-app-env.d.ts new file mode 100644 index 0000000..6431bc5 --- /dev/null +++ b/client-web/src/react-app-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/client-web/tsconfig.json b/client-web/tsconfig.json new file mode 100644 index 0000000..7b1d3c6 --- /dev/null +++ b/client-web/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "target": "es5", + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], + "allowJs": true, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react" + }, + "include": [ + "src" + ] +} diff --git a/client-web/tslint.json b/client-web/tslint.json new file mode 100644 index 0000000..848319f --- /dev/null +++ b/client-web/tslint.json @@ -0,0 +1,82 @@ +{ + "extends": "tslint:recommended", + "rules": { + "max-line-length": { + "options": [ + 120 + ] + }, + "new-parens": true, + "no-arg": true, + "no-bitwise": true, + "no-conditional-assignment": true, + "no-consecutive-blank-lines": false, + "no-console": { + "severity": "warning", + "options": [ + "debug", + "info", + "log", + "time", + "timeEnd", + "trace" + ] + } + }, + "jsRules": { + "max-line-length": { + "options": [ + 120 + ] + }, + "no-empty": true, + "member-ordering": [ + true, + { + "order": "fields-first" + } + ], + "no-magic-numbers": [ + true, + 1, + 2, + 3, + 0 + ], + "no-reference": true, + "ban-comma-operator": true, + "curly": [ + true, + "ignore-same-line" + ], + "no-console": [ + true, + "log", + "error" + ], + "no-duplicate-super": true, + "no-duplicate-switch-case": true, + "no-duplicate-variable": [ + true, + "check-parameters" + ], + "no-invalid-template-strings": true, + "switch-default": true, + "triple-equals": true, + "use-isnan": true, + "no-duplicate-imports": [ + true, + { + "allow-namespace-imports": true + } + ], + "arrow-return-shorthand": true, + "ordered-imports": true, + "whitespace": [ + true, + "check-branch", + "check-operator", + "check-typecast" + ] + } +} \ No newline at end of file diff --git a/pom.xml b/pom.xml index c3a79a4..c3c96e1 100644 --- a/pom.xml +++ b/pom.xml @@ -17,6 +17,10 @@ 11 11 + UTF-8 + 3.8.0 + 3.1.1 + 3.0.0-M5 @@ -24,6 +28,153 @@ org.springframework.boot spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-data-jpa + + + mysql + mysql-connector-java + runtime + + + + org.springframework.boot + spring-boot-starter-security + + + io.jsonwebtoken + jjwt + 0.9.1 + + + javax.xml.bind + jaxb-api + 2.3.0 + + + com.fasterxml.jackson.core + jackson-databind + 2.11.3 + + + + org.apache.commons + commons-lang3 + 3.2.1 + + + org.projectlombok + lombok + 1.18.16 + provided + + + + org.junit.jupiter + junit-jupiter + 5.7.0 + test + + + org.mockito + mockito-junit-jupiter + 3.6.0 + test + + + org.assertj + assertj-core + 3.18.1 + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + ${compiler.plugin.version} + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.apache.maven.plugins + maven-checkstyle-plugin + ${checkstyle.plugin.version} + + checkstyle.xml + UTF-8 + true + true + false + + + + validate + validate + + checkstyle + + + + + + org.apache.maven.plugins + maven-surefire-plugin + ${surefire.plugin.version} + + + + com.github.eirslett + frontend-maven-plugin + 1.10.3 + + client-web + + + + + install node and npm + + install-node-and-npm + + + v14.15.0 + 6.14.8 + + + + + npm install + + npm + + + install + + + + + npm run build:tslint + + npm + + + run build:tslint + + + + + + + diff --git a/src/main/java/com/kpi/project/App.java b/src/main/java/com/kpi/project/App.java index 2981ed9..949e52d 100644 --- a/src/main/java/com/kpi/project/App.java +++ b/src/main/java/com/kpi/project/App.java @@ -3,8 +3,10 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +@SuppressWarnings("checkstyle:hideutilityclassconstructor") @SpringBootApplication public class App { + public static void main(String[] args) { SpringApplication.run(App.class, args); } diff --git a/src/main/java/com/kpi/project/config/JacksonConfiguration.java b/src/main/java/com/kpi/project/config/JacksonConfiguration.java new file mode 100644 index 0000000..9ac35ec --- /dev/null +++ b/src/main/java/com/kpi/project/config/JacksonConfiguration.java @@ -0,0 +1,17 @@ +package com.kpi.project.config; + +import com.fasterxml.jackson.annotation.JsonInclude; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder; + +@Configuration +public class JacksonConfiguration { + + @Bean + public Jackson2ObjectMapperBuilder objectMapperBuilder() { + Jackson2ObjectMapperBuilder builder = new Jackson2ObjectMapperBuilder(); + builder.serializationInclusion(JsonInclude.Include.NON_NULL); + return builder; + } +} diff --git a/src/main/java/com/kpi/project/config/SecurityConfiguration.java b/src/main/java/com/kpi/project/config/SecurityConfiguration.java new file mode 100644 index 0000000..a0bb182 --- /dev/null +++ b/src/main/java/com/kpi/project/config/SecurityConfiguration.java @@ -0,0 +1,68 @@ +package com.kpi.project.config; + +import com.kpi.project.filter.JwtRequestFilter; +import com.kpi.project.service.UserService; +import com.kpi.project.util.model.JwtProperties; +import org.apache.commons.lang3.ArrayUtils; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Lazy; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +@EnableWebSecurity +public class SecurityConfiguration extends WebSecurityConfigurerAdapter { + + private final UserService userService; + private final JwtRequestFilter jwtRequestFilter; + + public SecurityConfiguration(@Lazy UserService userService, @Lazy JwtRequestFilter jwtRequestFilter) { + this.userService = userService; + this.jwtRequestFilter = jwtRequestFilter; + } + + @Override + protected void configure(AuthenticationManagerBuilder auth) throws Exception { + auth.userDetailsService(userService); + } + + @Override + protected void configure(HttpSecurity http) throws Exception { + final String[] testEndpoints = {"test/string", "/test/error", "/test/error2"}; + + http.csrf().disable() + .authorizeRequests() + .antMatchers( + ArrayUtils.addAll(testEndpoints, + "/authenticate", "/user/registration")) + .permitAll() + .anyRequest().authenticated() + .and().sessionManagement() + .sessionCreationPolicy(SessionCreationPolicy.STATELESS); + http.addFilterBefore(jwtRequestFilter, UsernamePasswordAuthenticationFilter.class); + } + + @Override + @Bean + public AuthenticationManager authenticationManagerBean() throws Exception { + return super.authenticationManagerBean(); + } + + @Bean + @ConfigurationProperties(prefix = "security") + public JwtProperties jwtProperties() { + return new JwtProperties(); + } + + @Bean + public PasswordEncoder getPasswordEncoder() { + return new BCryptPasswordEncoder(); + } +} diff --git a/src/main/java/com/kpi/project/filter/JwtRequestFilter.java b/src/main/java/com/kpi/project/filter/JwtRequestFilter.java new file mode 100644 index 0000000..be7e44e --- /dev/null +++ b/src/main/java/com/kpi/project/filter/JwtRequestFilter.java @@ -0,0 +1,54 @@ +package com.kpi.project.filter; + +import com.kpi.project.service.UserService; +import com.kpi.project.util.JwtUtil; +import org.apache.commons.lang3.StringUtils; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; + +@Component +public class JwtRequestFilter extends OncePerRequestFilter { + + private final JwtUtil jwtUtil; + private final UserService userService; + + public JwtRequestFilter(JwtUtil jwtUtil, UserService userService) { + this.jwtUtil = jwtUtil; + this.userService = userService; + } + + @Override + protected void doFilterInternal(HttpServletRequest httpServletRequest, + HttpServletResponse httpServletResponse, FilterChain filterChain) + throws ServletException, IOException { + final String authorizationHeader = httpServletRequest.getHeader("Authorization"); + String username = null; + String token = null; + if (StringUtils.isNotBlank(authorizationHeader) && authorizationHeader.startsWith("Bearer ")) { + token = authorizationHeader.substring(7); + username = jwtUtil.extractUsername(token); + } + if (StringUtils.isNotBlank(username) && SecurityContextHolder.getContext().getAuthentication() == null) { + final UserDetails userDetails = userService.loadUserByUsername(username); + if (jwtUtil.validateToken(token, userDetails)) { + final UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = + new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); + usernamePasswordAuthenticationToken + .setDetails(new WebAuthenticationDetailsSource().buildDetails(httpServletRequest)); + SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken); + + } + } + filterChain.doFilter(httpServletRequest, httpServletResponse); + } +} diff --git a/src/main/java/com/kpi/project/model/ErrorResponse.java b/src/main/java/com/kpi/project/model/ErrorResponse.java new file mode 100644 index 0000000..de1d92f --- /dev/null +++ b/src/main/java/com/kpi/project/model/ErrorResponse.java @@ -0,0 +1,11 @@ +package com.kpi.project.model; + +import com.kpi.project.model.enums.ErrorTypes; +import lombok.Data; + +@Data +public class ErrorResponse { + + ErrorTypes errorType; + String message; +} diff --git a/src/main/java/com/kpi/project/model/User.java b/src/main/java/com/kpi/project/model/User.java new file mode 100644 index 0000000..ac7ce51 --- /dev/null +++ b/src/main/java/com/kpi/project/model/User.java @@ -0,0 +1,76 @@ +package com.kpi.project.model; + +import com.kpi.project.model.enums.Role; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import javax.persistence.CollectionTable; +import javax.persistence.Column; +import javax.persistence.ElementCollection; +import javax.persistence.Entity; +import javax.persistence.EnumType; +import javax.persistence.Enumerated; +import javax.persistence.FetchType; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.JoinColumn; +import javax.persistence.Table; +import java.util.Collection; +import java.util.Collections; +import java.util.Set; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Entity +@Table(name = "USERS") +public class User implements UserDetails { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "USER_ID") + private Long id; + + @Column(name = "EMAIL", unique = true, nullable = false) + private String email; + + @Column(name = "USERNAME", unique = true, nullable = false) + private String username; + + @Column(name = "PASSWORD", nullable = false) + private String password; + + @ElementCollection(fetch = FetchType.EAGER) + @CollectionTable(name = "USER_ROLES", joinColumns = @JoinColumn(name = "USER_ID")) + @Enumerated(EnumType.STRING) + private Set roles; + + @Override + public Collection getAuthorities() { + return Collections.emptyList(); + } + + @Override + public boolean isAccountNonExpired() { + return true; + } + + @Override + public boolean isAccountNonLocked() { + return true; + } + + @Override + public boolean isCredentialsNonExpired() { + return true; + } + + @Override + public boolean isEnabled() { + return true; + } +} diff --git a/src/main/java/com/kpi/project/model/authentication/AuthenticationRequest.java b/src/main/java/com/kpi/project/model/authentication/AuthenticationRequest.java new file mode 100644 index 0000000..2f12d79 --- /dev/null +++ b/src/main/java/com/kpi/project/model/authentication/AuthenticationRequest.java @@ -0,0 +1,15 @@ +package com.kpi.project.model.authentication; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class AuthenticationRequest { + + private String username; + private String password; + +} diff --git a/src/main/java/com/kpi/project/model/authentication/AuthenticationResponse.java b/src/main/java/com/kpi/project/model/authentication/AuthenticationResponse.java new file mode 100644 index 0000000..4785504 --- /dev/null +++ b/src/main/java/com/kpi/project/model/authentication/AuthenticationResponse.java @@ -0,0 +1,13 @@ +package com.kpi.project.model.authentication; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class AuthenticationResponse { + + private String token; +} diff --git a/src/main/java/com/kpi/project/model/dto/UserDto.java b/src/main/java/com/kpi/project/model/dto/UserDto.java new file mode 100644 index 0000000..86c0180 --- /dev/null +++ b/src/main/java/com/kpi/project/model/dto/UserDto.java @@ -0,0 +1,24 @@ +package com.kpi.project.model.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.util.Set; + +@Data +public class UserDto { + + private Long id; + + private String username; + + private String email; + + @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) + private String password; + + private String matchingPassword; + + private Set roles; + +} diff --git a/src/main/java/com/kpi/project/model/enums/ErrorTypes.java b/src/main/java/com/kpi/project/model/enums/ErrorTypes.java new file mode 100644 index 0000000..40d7d18 --- /dev/null +++ b/src/main/java/com/kpi/project/model/enums/ErrorTypes.java @@ -0,0 +1,7 @@ +package com.kpi.project.model.enums; + +public enum ErrorTypes { + + server_error, + validation_error +} diff --git a/src/main/java/com/kpi/project/model/enums/Role.java b/src/main/java/com/kpi/project/model/enums/Role.java new file mode 100644 index 0000000..1bee412 --- /dev/null +++ b/src/main/java/com/kpi/project/model/enums/Role.java @@ -0,0 +1,26 @@ +package com.kpi.project.model.enums; + +import org.springframework.security.core.GrantedAuthority; + +@SuppressWarnings("checkstyle:hideutilityclassconstructor") +public enum Role implements GrantedAuthority { + + ADMIN(Code.ADMIN), + USER(Code.USER); + + private final String authority; + + Role(String authority) { + this.authority = authority; + } + + @Override + public String getAuthority() { + return authority; + } + + public class Code { + public static final String ADMIN = "ROLE_ADMIN"; + public static final String USER = "ROLE_USER"; + } +} diff --git a/src/main/java/com/kpi/project/model/exception/ValidatorException.java b/src/main/java/com/kpi/project/model/exception/ValidatorException.java new file mode 100644 index 0000000..ff83bb1 --- /dev/null +++ b/src/main/java/com/kpi/project/model/exception/ValidatorException.java @@ -0,0 +1,12 @@ +package com.kpi.project.model.exception; + +public class ValidatorException extends IllegalArgumentException { + + public ValidatorException(String errorMessage) { + super(errorMessage); + } + + public ValidatorException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/com/kpi/project/model/mapper/UserMapper.java b/src/main/java/com/kpi/project/model/mapper/UserMapper.java new file mode 100644 index 0000000..830b6a4 --- /dev/null +++ b/src/main/java/com/kpi/project/model/mapper/UserMapper.java @@ -0,0 +1,35 @@ +package com.kpi.project.model.mapper; + +import com.kpi.project.model.User; +import com.kpi.project.model.dto.UserDto; +import org.springframework.stereotype.Component; + +import java.util.Set; +import java.util.stream.Collectors; + +@Component +public class UserMapper { + + public User dtoToUser(UserDto userDto) { + final User user = new User(); + user.setEmail(userDto.getEmail()); + user.setPassword(userDto.getPassword()); + user.setUsername(userDto.getUsername()); + + return user; + } + + public UserDto userToDto(User user) { + final UserDto userDto = new UserDto(); + userDto.setEmail(user.getEmail()); + userDto.setPassword(user.getPassword()); + userDto.setUsername(user.getUsername()); + + final Set newRoles = user.getRoles().stream() + .map(Enum::toString) + .collect(Collectors.toSet()); + userDto.setRoles(newRoles); + + return userDto; + } +} diff --git a/src/main/java/com/kpi/project/repository/UserRepository.java b/src/main/java/com/kpi/project/repository/UserRepository.java new file mode 100644 index 0000000..f027b11 --- /dev/null +++ b/src/main/java/com/kpi/project/repository/UserRepository.java @@ -0,0 +1,23 @@ +package com.kpi.project.repository; + +import com.kpi.project.model.User; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +@Repository +public interface UserRepository extends JpaRepository { + + @Query("SELECT u FROM User u WHERE u.email = :loginParam or u.username = :loginParam") + User loadByEmailOrUsername(@Param("loginParam") String loginParam); + + User findByEmailOrUsername(String email, String username); + + User findByEmail(String email); + + User findByUsername(String username); + + @Query("SELECT u FROM User u WHERE u.id = :idParam") + User findByIdIdentifier(@Param("idParam") Long id); +} diff --git a/src/main/java/com/kpi/project/resource/ErrorsResource.java b/src/main/java/com/kpi/project/resource/ErrorsResource.java new file mode 100644 index 0000000..8945a54 --- /dev/null +++ b/src/main/java/com/kpi/project/resource/ErrorsResource.java @@ -0,0 +1,36 @@ +package com.kpi.project.resource; + +import com.kpi.project.model.ErrorResponse; +import com.kpi.project.model.enums.ErrorTypes; +import com.kpi.project.model.exception.ValidatorException; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.context.request.WebRequest; +import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; + +@ControllerAdvice +public class ErrorsResource extends ResponseEntityExceptionHandler { + + @ExceptionHandler(value = {ValidatorException.class}) + protected ResponseEntity handleValidationExceptions(ValidatorException ex, WebRequest request) { + + final ErrorResponse errorResponse = new ErrorResponse(); + errorResponse.setErrorType(ErrorTypes.validation_error); + errorResponse.setMessage(ex.getMessage()); + + return handleExceptionInternal(ex, errorResponse, new HttpHeaders(), HttpStatus.BAD_REQUEST, request); + } + + @ExceptionHandler(value = {Exception.class}) + protected ResponseEntity handleUncaughtException(Exception ex, WebRequest request) { + + final ErrorResponse errorResponse = new ErrorResponse(); + errorResponse.setErrorType(ErrorTypes.server_error); + errorResponse.setMessage(ex.getMessage()); + + return handleExceptionInternal(ex, errorResponse, new HttpHeaders(), HttpStatus.BAD_REQUEST, request); + } +} diff --git a/src/main/java/com/kpi/project/resource/SystemResource.java b/src/main/java/com/kpi/project/resource/SystemResource.java new file mode 100644 index 0000000..c28cb7d --- /dev/null +++ b/src/main/java/com/kpi/project/resource/SystemResource.java @@ -0,0 +1,42 @@ +package com.kpi.project.resource; + +import com.kpi.project.model.authentication.AuthenticationRequest; +import com.kpi.project.model.authentication.AuthenticationResponse; +import com.kpi.project.service.UserService; +import com.kpi.project.util.JwtUtil; +import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class SystemResource { + + private final AuthenticationManager authenticationManager; + private final UserService userService; + private final JwtUtil jwtUtil; + + public SystemResource(AuthenticationManager authenticationManager, UserService userService, JwtUtil jwtUtil) { + this.authenticationManager = authenticationManager; + this.userService = userService; + this.jwtUtil = jwtUtil; + } + + @PostMapping("/authenticate") + public ResponseEntity authenticate(@RequestBody AuthenticationRequest authenticationRequest) throws Exception { + try { + authenticationManager.authenticate( + new UsernamePasswordAuthenticationToken(authenticationRequest.getUsername(), + authenticationRequest.getPassword())); + } catch (BadCredentialsException e) { + throw new Exception("incorrect password or login", e); + } + final UserDetails userDetails = userService.loadUserByUsername(authenticationRequest.getUsername()); + final String jwt = jwtUtil.generateToken(userDetails); + return ResponseEntity.ok(new AuthenticationResponse(jwt)); + } +} diff --git a/src/main/java/com/kpi/project/resource/TestController.java b/src/main/java/com/kpi/project/resource/TestController.java new file mode 100644 index 0000000..124085e --- /dev/null +++ b/src/main/java/com/kpi/project/resource/TestController.java @@ -0,0 +1,27 @@ +package com.kpi.project.resource; + +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class TestController { + @RequestMapping("test/string") + public String test() { + return "string.ok"; + } + + @RequestMapping("/test/error") + public Object illegalArgument() { + throw new IllegalArgumentException("some exception"); + } + + @RequestMapping("/test/error2") + public Object exception() throws Exception { + throw new Exception("some exception"); + } + + @RequestMapping("/test/secured") + public Object getSecured() throws Exception { + throw new Exception("secured point"); + } +} diff --git a/src/main/java/com/kpi/project/resource/UserResource.java b/src/main/java/com/kpi/project/resource/UserResource.java new file mode 100644 index 0000000..0d59162 --- /dev/null +++ b/src/main/java/com/kpi/project/resource/UserResource.java @@ -0,0 +1,39 @@ +package com.kpi.project.resource; + +import com.kpi.project.model.dto.UserDto; +import com.kpi.project.service.UserService; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class UserResource { + + private final UserService userService; + + public UserResource(UserService userService) { + this.userService = userService; + } + + @PostMapping("/user/registration") + public ResponseEntity showRegistrationForm(@RequestBody UserDto userDto) { + + return ResponseEntity.ok(userService.saveUser(userDto)); + } + + @PutMapping("/user/update/roles") + public ResponseEntity updateUsersRole(@RequestBody UserDto userDto) { + final UserDto user = userService.updateUserRoles(userDto); + + return ResponseEntity.ok(user); + } + + @PutMapping("/user/change/password") + public ResponseEntity changeUsersPassword(@RequestBody UserDto userDto) { + final UserDto user = userService.changeUserPassword(userDto); + + return ResponseEntity.ok(user); + } +} diff --git a/src/main/java/com/kpi/project/service/UserService.java b/src/main/java/com/kpi/project/service/UserService.java new file mode 100644 index 0000000..e0242d0 --- /dev/null +++ b/src/main/java/com/kpi/project/service/UserService.java @@ -0,0 +1,74 @@ +package com.kpi.project.service; + +import com.kpi.project.model.User; +import com.kpi.project.model.dto.UserDto; +import com.kpi.project.model.enums.Role; +import com.kpi.project.model.mapper.UserMapper; +import com.kpi.project.repository.UserRepository; +import com.kpi.project.validate.UserValidator; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; + +import java.util.Collections; +import java.util.Set; +import java.util.stream.Collectors; + +@Service +public class UserService implements UserDetailsService { + + private final UserRepository userRepository; + private final UserValidator userValidator; + private final UserMapper userMapper; + private final PasswordEncoder passwordEncoder; + + public UserService(UserRepository userRepository, UserValidator userValidator, + UserMapper userMapper, PasswordEncoder passwordEncoder) { + this.userRepository = userRepository; + this.userValidator = userValidator; + this.userMapper = userMapper; + this.passwordEncoder = passwordEncoder; + } + + public UserDto changeUserPassword(UserDto userDto) throws UsernameNotFoundException { + final String password = userDto.getPassword(); + userValidator.validatePassword(password, userDto.getMatchingPassword()); + final Long userId = userDto.getId(); + userValidator.validateUserExistence(userId); + userValidator.validateUserPermissions(userId); + final User updatedUser = userRepository.findByIdIdentifier(userId); + updatedUser.setPassword(passwordEncoder.encode(password)); + + return userMapper.userToDto(userRepository.save(updatedUser)); + } + + public UserDto updateUserRoles(UserDto userDto) throws UsernameNotFoundException { + userValidator.userRolesUpdateValidator(userDto.getId(), userDto.getRoles()); + + final User userWithNewRoles = userRepository.findByIdIdentifier(userDto.getId()); + final Set newRoles = userDto.getRoles().stream() + .map(Role::valueOf) + .collect(Collectors.toSet()); + userWithNewRoles.setRoles(newRoles); + + return userMapper.userToDto(userRepository.save(userWithNewRoles)); + } + + @Override + public User loadUserByUsername(String login) throws UsernameNotFoundException { + + return userRepository.loadByEmailOrUsername(login); + } + + public UserDto saveUser(UserDto userDto) { + final String userPassword = userDto.getPassword(); + userValidator.validatePassword(userPassword, userDto.getMatchingPassword()); + userValidator.validateUser(userDto.getEmail(), userDto.getUsername()); + final User user = userMapper.dtoToUser(userDto); + user.setRoles(Collections.singleton(Role.USER)); + user.setPassword(passwordEncoder.encode(userPassword)); + + return userMapper.userToDto(userRepository.save(user)); + } +} diff --git a/src/main/java/com/kpi/project/util/JwtUtil.java b/src/main/java/com/kpi/project/util/JwtUtil.java new file mode 100644 index 0000000..07b6818 --- /dev/null +++ b/src/main/java/com/kpi/project/util/JwtUtil.java @@ -0,0 +1,60 @@ +package com.kpi.project.util; + +import com.kpi.project.util.model.JwtProperties; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Service; + +import java.util.Date; +import java.util.HashMap; +import java.util.Map; +import java.util.function.Function; + +@Service +public class JwtUtil { + + private final JwtProperties jwtProperties; + + public JwtUtil(JwtProperties jwtProperties) { + this.jwtProperties = jwtProperties; + } + + public String extractUsername(String token) { + return extractClaim(token, Claims::getSubject); + } + + public Date extractExpiration(String token) { + return extractClaim(token, Claims::getExpiration); + } + + public T extractClaim(String token, Function claimsResolver) { + final Claims claims = extractAllClaims(token); + return claimsResolver.apply(claims); + } + + private Claims extractAllClaims(String token) { + return Jwts.parser().setSigningKey(jwtProperties.getSigningKey()).parseClaimsJws(token).getBody(); + } + + private Boolean isTokenExpired(String token) { + return extractExpiration(token).before(new Date()); + } + + public String generateToken(UserDetails userDetails) { + final Map claims = new HashMap<>(); + return createToken(claims, userDetails.getUsername()); + } + + private String createToken(Map claims, String subject) { + return Jwts.builder().setClaims(claims).setSubject(subject).setIssuedAt(new Date(System.currentTimeMillis())) + .setExpiration(new Date(System.currentTimeMillis() + jwtProperties.getTokenExpirationTime())) + .signWith(SignatureAlgorithm.HS256, jwtProperties.getSigningKey()).compact(); + } + + public Boolean validateToken(String token, UserDetails userDetails) { + final String username = extractUsername(token); + return (username.equals(userDetails.getUsername())) && !isTokenExpired(token); + } +} diff --git a/src/main/java/com/kpi/project/util/model/JwtProperties.java b/src/main/java/com/kpi/project/util/model/JwtProperties.java new file mode 100644 index 0000000..3fbf037 --- /dev/null +++ b/src/main/java/com/kpi/project/util/model/JwtProperties.java @@ -0,0 +1,14 @@ +package com.kpi.project.util.model; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@NoArgsConstructor +@Getter +@Setter +public class JwtProperties { + + private String signingKey; + private int tokenExpirationTime; +} diff --git a/src/main/java/com/kpi/project/validate/UserValidator.java b/src/main/java/com/kpi/project/validate/UserValidator.java new file mode 100644 index 0000000..145a6e1 --- /dev/null +++ b/src/main/java/com/kpi/project/validate/UserValidator.java @@ -0,0 +1,81 @@ +package com.kpi.project.validate; + +import com.kpi.project.model.User; +import com.kpi.project.model.enums.Role; +import com.kpi.project.model.exception.ValidatorException; +import com.kpi.project.repository.UserRepository; +import org.apache.commons.lang3.StringUtils; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; + +import java.util.Objects; +import java.util.Set; + +@Component +public class UserValidator { + + private final UserRepository userRepository; + + public UserValidator(UserRepository userRepository) { + this.userRepository = userRepository; + } + + public void userRolesUpdateValidator(Long userId, Set roles) { + for (String role : roles) { + try { + Role.valueOf(role); + } catch (Exception e) { + throw new ValidatorException(String.format("Not existing role: %s", role)); + } + } + if (Objects.isNull(userRepository.findByIdIdentifier(userId))) { + throw new ValidatorException(String.format("User with id : %s, not exists", userId)); + } + } + + public void validateUserPermissions(Long id) { + final Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + + final String userName = authentication != null ? authentication.getName() : null; + final User userInContext = userRepository.findByUsername(userName); + final boolean isAdmin = userInContext.getRoles().stream().anyMatch(role -> role == Role.ADMIN); + + if (!isAdmin && !id.equals(userInContext.getId())) { + throw new ValidatorException("You do not have permission to change password"); + } + } + + public void validateUserExistence(Long id) { + if (Objects.isNull(userRepository.findByIdIdentifier(id))) { + throw new ValidatorException(String.format("User with id : %s, not exists", id)); + } + } + + public void validatePassword(String password, String matchingPassword) { + if (!Objects.equals(password, matchingPassword)) { + throw new ValidatorException("Passwords does not match"); + } + if (password.length() < 4) { + throw new ValidatorException("Password length must be minimum of 4 symbols"); + } + } + + public void validateUser(String userEmail, String userName) { + if (StringUtils.isBlank(userEmail)) { + throw new ValidatorException("Email should be present"); + } + if (StringUtils.isBlank(userName)) { + throw new ValidatorException("Username should be present"); + } + + final User userByEmail = userRepository.findByEmail(userEmail); + final User userByUsername = userRepository.findByUsername(userName); + if (Objects.nonNull(userByEmail)) { + throw new ValidatorException("Email already exists"); + } + if (Objects.nonNull(userByUsername)) { + throw new ValidatorException("Username already exists"); + } + } +} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml new file mode 100644 index 0000000..09104e8 --- /dev/null +++ b/src/main/resources/application.yaml @@ -0,0 +1,18 @@ +# Security config +security: + token_expiration_time: 1800000 + signing_key: some-signing-key + +# DB config +spring: + datasource: + password: root + url: jdbc:mysql://localhost:3306/KpiSocialNetwork + username: root + jpa: + hibernate: + ddl-auto: update + +# Server properties +server: + port: 8092 diff --git a/src/test/java/com/kpi/project/service/UserServiceTest.java b/src/test/java/com/kpi/project/service/UserServiceTest.java new file mode 100644 index 0000000..78aafda --- /dev/null +++ b/src/test/java/com/kpi/project/service/UserServiceTest.java @@ -0,0 +1,130 @@ +package com.kpi.project.service; + +import com.kpi.project.model.User; +import com.kpi.project.model.dto.UserDto; +import com.kpi.project.model.enums.Role; +import com.kpi.project.model.mapper.UserMapper; +import com.kpi.project.repository.UserRepository; +import com.kpi.project.validate.UserValidator; +import org.junit.jupiter.api.BeforeEach; +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 org.springframework.security.crypto.password.PasswordEncoder; + +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +public class UserServiceTest { + + @Mock + private UserRepository userRepository; + + @Mock + private UserMapper userMapper; + + @Mock + private UserValidator userValidator; + + @Mock + private PasswordEncoder passwordEncoder; + + private User user; + + private UserDto userDto; + + @InjectMocks + private UserService testingInstance; + + @BeforeEach + public void setUp() { + user = new User(1L, "mail@mail.com", "username", + "password", Collections.singleton(Role.ADMIN)); + userDto = new UserDto(); + userDto.setPassword("password"); + userDto.setMatchingPassword("password"); + userDto.setUsername("username"); + userDto.setEmail("mail@mail.com"); + userDto.setId(1L); + } + + @Test + public void loadUserByUsernameShouldReturnUserFoundByEmailOrUsername() { + // given + given(userRepository.loadByEmailOrUsername("login")).willReturn(user); + + // when + final User actualUser = testingInstance.loadUserByUsername("login"); + + // then + verify(userRepository).loadByEmailOrUsername("login"); + assertThat(actualUser).isEqualTo(user); + } + + @Test + public void saveUserShouldReturnSavedUser() { + // given + given(userMapper.dtoToUser(userDto)).willReturn(user); + given(userMapper.userToDto(user)).willReturn(userDto); + given(userRepository.save(user)).willReturn(user); + given(passwordEncoder.encode("password")).willReturn("hashedPassword"); + + // when + final UserDto actualUser = testingInstance.saveUser(userDto); + + // then + verify(userMapper).dtoToUser(userDto); + verify(userMapper).userToDto(user); + verify(userRepository).save(user); + assertThat(actualUser).isEqualTo(userDto); + } + + @Test + public void updateUserRolesShouldReturnUpdatedUser() { + // given + final Set updatedRoles = Stream.of("ADMIN", "USER") + .collect(Collectors.toCollection(HashSet::new)); + userDto.setRoles(updatedRoles); + given(userRepository.save(any())).willReturn(user); + given(userMapper.userToDto(user)).willReturn(userDto); + given(userRepository.findByIdIdentifier(1L)).willReturn(user); + + // when + final UserDto actualUser = testingInstance.updateUserRoles(userDto); + + // then + assertThat(actualUser) + .isNotNull() + .extracting(UserDto::getRoles) + .isEqualTo(updatedRoles); + } + + @Test + public void changeUserPasswordShouldUpdateUsersPassword() { + // given + userDto.setPassword("passwordChange"); + given(userRepository.save(any())).willReturn(user); + given(userMapper.userToDto(user)).willReturn(userDto); + given(userRepository.findByIdIdentifier(1L)).willReturn(user); + + // when + final UserDto actualUser = testingInstance.changeUserPassword(userDto); + + // then + assertThat(actualUser) + .isNotNull() + .extracting(UserDto::getPassword) + .isEqualTo("passwordChange"); + } +} diff --git a/src/test/java/com/kpi/project/validator/UserValidatorTest.java b/src/test/java/com/kpi/project/validator/UserValidatorTest.java new file mode 100644 index 0000000..556a615 --- /dev/null +++ b/src/test/java/com/kpi/project/validator/UserValidatorTest.java @@ -0,0 +1,152 @@ +package com.kpi.project.validator; + +import com.kpi.project.model.User; +import com.kpi.project.model.exception.ValidatorException; +import com.kpi.project.repository.UserRepository; +import com.kpi.project.validate.UserValidator; +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.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; + +@ExtendWith(MockitoExtension.class) +public class UserValidatorTest { + + @Mock + private UserRepository userRepository; + + @InjectMocks + private UserValidator testingInstance; + + @Test + public void validateUserShouldThrowExceptionIfPasswordDoesNotMatch() { + // expected + assertThatExceptionOfType(ValidatorException.class) + .isThrownBy(() -> testingInstance.validatePassword("password", "wrongPassword")) + .withMessage("Passwords does not match"); + } + + @Test + public void validateUserShouldThrowExceptionIfPasswordHasIncorrectLength() { + // expected + assertThatExceptionOfType(ValidatorException.class) + .isThrownBy(() -> testingInstance.validatePassword("pa", "pa")) + .withMessage("Password length must be minimum of 4 symbols"); + } + + @Test + public void validateUserShouldThrowExceptionIfEmailIsNotPresent() { + // expected + assertThatExceptionOfType(ValidatorException.class) + .isThrownBy(() -> testingInstance.validateUser("", "username")) + .withMessage("Email should be present"); + } + + @Test + public void validateUserShouldThrowExceptionIfUserNameIsNotPresent() { + // expected + assertThatExceptionOfType(ValidatorException.class) + .isThrownBy(() -> testingInstance.validateUser("email@mail.com", "")) + .withMessage("Username should be present"); + } + + @Test + public void validateUserShouldThrowExceptionIfEmailAlreadyExists() { + // given + final User userModel = new User(); + userModel.setEmail("email@mail.com"); + userModel.setUsername("username2.0"); + given(userRepository.findByEmail("email@mail.com")).willReturn(userModel); + given(userRepository.findByUsername("username")).willReturn(null); + + // expected + assertThatExceptionOfType(ValidatorException.class) + .isThrownBy(() -> testingInstance.validateUser("email@mail.com", "username")) + .withMessage("Email already exists"); + } + + @Test + public void validateUserShouldThrowExceptionIfUserNameAlreadyExists() { + // given + final User userModel = new User(); + userModel.setEmail("email2.0@mail.com"); + userModel.setUsername("username"); + given(userRepository.findByEmail("email@mail.com")).willReturn(null); + given(userRepository.findByUsername("username")).willReturn(userModel); + + // expected + assertThatExceptionOfType(ValidatorException.class) + .isThrownBy(() -> testingInstance.validateUser("email@mail.com", "username")) + .withMessage("Username already exists"); + } + + @Test + public void validateUserShouldThrowExceptionIfUserIsNotExist() { + // given + final Set roles = new HashSet(Arrays.asList("ADMIN", "USER")); + + // expected + assertThatExceptionOfType(ValidatorException.class) + .isThrownBy(() -> testingInstance.userRolesUpdateValidator(1L, roles)) + .withMessage("User with id : 1, not exists"); + } + + @Test + public void validateUserShouldThrowExceptionIfRolesIsNotExist() { + // given + final Set roles = new HashSet(Arrays.asList("NOT_EXISTED_ROLE")); + + // expected + assertThatExceptionOfType(ValidatorException.class) + .isThrownBy(() -> testingInstance.userRolesUpdateValidator(1L, roles)) + .withMessage("Not existing role: NOT_EXISTED_ROLE"); + } + + @Test + public void validateUserHavePermissionShouldThrowExceptionYouDoNotHavePermission() { + // given + final User someUser = new User(); + someUser.setId(2L); + someUser.setRoles(Collections.emptySet()); + given(userRepository.findByUsername(any())).willReturn(someUser); + + // expected + assertThatExceptionOfType(ValidatorException.class) + .isThrownBy(() -> testingInstance.validateUserPermissions(1L)) + .withMessage("You do not have permission to change password"); + } + + @Test + public void validateUserHavePermissionShouldNotThrowException() { + // given + final User someUser = new User(); + someUser.setId(1L); + someUser.setRoles(Collections.emptySet()); + given(userRepository.findByUsername(any())).willReturn(someUser); + + // expected + assertDoesNotThrow(() -> testingInstance.validateUserPermissions(1L)); + } + + @Test + public void validateUserExistenceShouldThrowExceptionUserIsNotExist() { + //given + given(userRepository.findByIdIdentifier(any())).willReturn(null); + + //expected + assertThatExceptionOfType(ValidatorException.class) + .isThrownBy(() -> testingInstance.validateUserExistence(25L)) + .withMessage("User with id : 25, not exists"); + } +}