frontend auth

This commit is contained in:
Stepan Usatiuk
2023-12-16 16:19:05 +01:00
parent fc396f9ac6
commit ab566ebf24
37 changed files with 781 additions and 79 deletions

View File

@@ -27,6 +27,7 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.security:spring-security-test'
compileOnly 'org.projectlombok:lombok'
developmentOnly 'org.springframework.boot:spring-boot-devtools'
@@ -34,6 +35,8 @@ dependencies {
runtimeOnly 'org.mariadb.jdbc:mariadb-java-client'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
// For TestRestTemplate as default client can't handle UNAUTHORIZED response
testImplementation 'org.apache.httpcomponents.client5:httpclient5:5.3'
implementation 'io.jsonwebtoken:jjwt-api:0.12.3'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.3'

View File

@@ -0,0 +1,22 @@
package com.usatiuk.tjv.y.server.controller;
import com.usatiuk.tjv.y.server.dto.ErrorTo;
import jakarta.validation.ConstraintViolation;
import jakarta.validation.ConstraintViolationException;
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 ApiExceptionHandler extends ResponseEntityExceptionHandler {
@ExceptionHandler(value = {ConstraintViolationException.class})
protected ResponseEntity<Object> handleConstraintViolation(ConstraintViolationException ex, WebRequest request) {
return handleExceptionInternal(ex,
new ErrorTo(ex.getConstraintViolations().stream().map(ConstraintViolation::getMessage), HttpStatus.BAD_REQUEST.value()),
new HttpHeaders(), HttpStatus.BAD_REQUEST, request);
}
}

View File

@@ -1,7 +1,7 @@
package com.usatiuk.tjv.y.server.controller;
import com.usatiuk.tjv.y.server.dto.PersonSignupTo;
import com.usatiuk.tjv.y.server.dto.PersonTo;
import com.usatiuk.tjv.y.server.dto.PersonSignupRequest;
import com.usatiuk.tjv.y.server.dto.converters.PersonMapper;
import com.usatiuk.tjv.y.server.entity.Person;
import com.usatiuk.tjv.y.server.service.PersonService;
@@ -24,7 +24,7 @@ public class PersonController {
}
@PostMapping
public PersonTo signup(@RequestBody PersonSignupRequest signupRequest) throws UserAlreadyExistsException {
public PersonTo signup(@RequestBody PersonSignupTo signupRequest) throws UserAlreadyExistsException {
Person toCreate = new Person();
toCreate.setUsername(signupRequest.username())
.setPassword(signupRequest.password())
@@ -44,6 +44,15 @@ public class PersonController {
return PersonMapper.makeDto(found.get());
}
@GetMapping(path = "")
public PersonTo getSelf(Principal principal) throws UserNotFoundException {
Optional<Person> found = personService.readById(principal.getName());
if (found.isEmpty()) throw new UserNotFoundException();
return PersonMapper.makeDto(found.get());
}
@GetMapping(path = "/followers")
public Stream<PersonTo> getFollowers(Principal principal) throws UserNotFoundException {
return personService.getFollowers(principal.getName()).stream().map(PersonMapper::makeDto);

View File

@@ -1,6 +1,6 @@
package com.usatiuk.tjv.y.server.controller;
import com.usatiuk.tjv.y.server.dto.PostCreate;
import com.usatiuk.tjv.y.server.dto.PostCreateTo;
import com.usatiuk.tjv.y.server.dto.PostTo;
import com.usatiuk.tjv.y.server.dto.converters.PostMapper;
import com.usatiuk.tjv.y.server.entity.Person;
@@ -28,10 +28,10 @@ public class PostController {
}
@PostMapping
public PostTo createPost(Principal principal, @RequestBody PostCreate postCreate) {
public PostTo createPost(Principal principal, @RequestBody PostCreateTo postCreateTo) {
Post post = new Post();
post.setAuthor(entityManager.getReference(Person.class, principal.getName()));
post.setText(postCreate.text());
post.setText(postCreateTo.text());
return PostMapper.makeDto(postService.create(post));
}

View File

@@ -1,17 +1,14 @@
package com.usatiuk.tjv.y.server.controller;
import com.usatiuk.tjv.y.server.dto.TokenRequest;
import com.usatiuk.tjv.y.server.dto.TokenResponse;
import com.usatiuk.tjv.y.server.dto.TokenRequestTo;
import com.usatiuk.tjv.y.server.dto.TokenResponseTo;
import com.usatiuk.tjv.y.server.entity.Person;
import com.usatiuk.tjv.y.server.service.PersonService;
import com.usatiuk.tjv.y.server.service.TokenService;
import com.usatiuk.tjv.y.server.service.exceptions.UserNotFoundException;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import java.util.Optional;
@@ -28,12 +25,12 @@ public class TokenController {
}
@PostMapping
public TokenResponse request(@RequestBody TokenRequest tokenRequest) throws UserNotFoundException {
Optional<Person> found = personService.login(tokenRequest.username(), tokenRequest.password());
public TokenResponseTo request(@RequestBody TokenRequestTo tokenRequestTo) throws UserNotFoundException {
Optional<Person> found = personService.login(tokenRequestTo.username(), tokenRequestTo.password());
if (found.isEmpty()) throw new ResponseStatusException(HttpStatus.NOT_FOUND);
return new TokenResponse(tokenService.generateToken(found.get().getId()));
return new TokenResponseTo(tokenService.generateToken(found.get().getId()));
}
}

View File

@@ -0,0 +1,14 @@
package com.usatiuk.tjv.y.server.dto;
import java.util.Collection;
import java.util.stream.Stream;
public record ErrorTo(String[] errors, Integer code) {
public ErrorTo(Collection<String> errors, Integer code) {
this(errors.toArray(String[]::new), code);
}
public ErrorTo(Stream<String> errors, Integer code) {
this(errors.toArray(String[]::new), code);
}
}

View File

@@ -1,4 +0,0 @@
package com.usatiuk.tjv.y.server.dto;
public record PersonSignupRequest(String username, String fullName, String password) {
}

View File

@@ -0,0 +1,4 @@
package com.usatiuk.tjv.y.server.dto;
public record PersonSignupTo(String username, String fullName, String password) {
}

View File

@@ -1,4 +0,0 @@
package com.usatiuk.tjv.y.server.dto;
public record PostCreate(String text) {
}

View File

@@ -0,0 +1,4 @@
package com.usatiuk.tjv.y.server.dto;
public record PostCreateTo(String text) {
}

View File

@@ -1,4 +0,0 @@
package com.usatiuk.tjv.y.server.dto;
public record TokenRequest(String username, String password) {
}

View File

@@ -0,0 +1,4 @@
package com.usatiuk.tjv.y.server.dto;
public record TokenRequestTo(String username, String password) {
}

View File

@@ -1,4 +0,0 @@
package com.usatiuk.tjv.y.server.dto;
public record TokenResponse(String token) {
}

View File

@@ -0,0 +1,4 @@
package com.usatiuk.tjv.y.server.dto;
public record TokenResponseTo(String token) {
}

View File

@@ -1,6 +1,8 @@
package com.usatiuk.tjv.y.server.entity;
import jakarta.persistence.*;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@@ -21,10 +23,15 @@ public class Person implements EntityWithId<String> {
@GeneratedValue(strategy = GenerationType.UUID)
private String uuid;
@Size(max = 100, message = "Username can't be longer than 100")
@NotBlank(message = "Username can't be empty")
@Column(unique = true)
private String username;
@Size(max = 100, message = "Name can't be longer than 100")
@NotBlank(message = "Name can't be empty")
private String fullName;
@NotBlank(message = "Password can't be empty")
private String password;
@OneToMany(mappedBy = "author")

View File

@@ -1,18 +1,33 @@
package com.usatiuk.tjv.y.server.security;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.usatiuk.tjv.y.server.dto.ErrorTo;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.security.config.Customizer;
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.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.servlet.util.matcher.MvcRequestMatcher;
import org.springframework.stereotype.Component;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.servlet.handler.HandlerMappingIntrospector;
import static org.springframework.security.config.Customizer.withDefaults;
import java.io.IOException;
import java.io.OutputStream;
import java.util.List;
@Configuration
@EnableWebSecurity
@@ -23,14 +38,26 @@ public class WebSecurityConfig {
this.jwtRequestFilter = jwtRequestFilter;
}
@Bean
MvcRequestMatcher.Builder mvc(HandlerMappingIntrospector introspector) {
return new MvcRequestMatcher.Builder(introspector);
@Component
class ErrorAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException)
throws IOException {
var err = new ErrorTo(List.of("Authentication failed"), HttpStatus.UNAUTHORIZED.value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
OutputStream responseStream = response.getOutputStream();
ObjectMapper mapper = new ObjectMapper();
mapper.writeValue(responseStream, err);
responseStream.flush();
}
}
@Bean
public SecurityFilterChain configure(HttpSecurity http, MvcRequestMatcher.Builder mvc) throws Exception {
return http.cors(withDefaults())
public SecurityFilterChain configure(HttpSecurity http, HandlerMappingIntrospector introspector, AuthenticationEntryPoint authenticationEntryPoint) throws Exception {
MvcRequestMatcher.Builder mvc = new MvcRequestMatcher.Builder(introspector);
return http.cors(Customizer.withDefaults())
.csrf(AbstractHttpConfigurer::disable)
.authorizeHttpRequests((authorize) -> authorize
.requestMatchers(mvc.pattern(HttpMethod.GET, "/post/*")).permitAll()
@@ -41,7 +68,15 @@ public class WebSecurityConfig {
.anyRequest().hasAuthority(UserRoles.ROLE_USER.name()))
.sessionManagement((session) -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.addFilterBefore(jwtRequestFilter, UsernamePasswordAuthenticationFilter.class)
.exceptionHandling(c -> c.authenticationEntryPoint(authenticationEntryPoint))
.build();
}
@Bean
CorsConfigurationSource corsConfigurationSource() {
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", new CorsConfiguration().applyPermitDefaultValues());
return source;
}
}

View File

@@ -1 +1,3 @@
jwt.secret=JKLASJKLASJKLJHKLDFAHJKFDSHJKFJHKDSHJKFHJKSDFJHKSDJHKFJHKS98346783467899782345jkhgsdoigh938g
jwt.secret=JKLASJKLASJKLJHKLDFAHJKFDSHJKFJHKDSHJKFHJKSDFJHKSDJHKFJHKS98346783467899782345jkhgsdoigh938g
logging.level.root=DEBUG
logging.level.org.springframework.security=DEBUG

View File

@@ -1,6 +1,6 @@
package com.usatiuk.tjv.y.server.controller;
import com.usatiuk.tjv.y.server.dto.TokenResponse;
import com.usatiuk.tjv.y.server.dto.TokenResponseTo;
import com.usatiuk.tjv.y.server.entity.Person;
import com.usatiuk.tjv.y.server.entity.Post;
import com.usatiuk.tjv.y.server.repository.PersonRepository;
@@ -41,18 +41,18 @@ public abstract class DemoDataDbTest {
protected static final String person1Password = "p1p";
protected Person person1;
protected TokenResponse person1Auth;
protected TokenResponseTo person1Auth;
protected static final String person2Password = "p2p";
protected Person person2;
protected TokenResponse person2Auth;
protected TokenResponseTo person2Auth;
protected static final String person3Password = "p3p";
protected Person person3;
protected TokenResponse person3Auth;
protected TokenResponseTo person3Auth;
protected Post post1;
protected Post post2;
protected HttpHeaders createAuthHeaders(TokenResponse personAuth) {
protected HttpHeaders createAuthHeaders(TokenResponseTo personAuth) {
HttpHeaders headers = new HttpHeaders();
headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON));
headers.add(HttpHeaders.AUTHORIZATION, "Bearer " + personAuth.token());
@@ -67,20 +67,20 @@ public abstract class DemoDataDbTest {
.setUsername("person1")
.setFullName("Person 1")
.setPassword(passwordEncoder.encode(person1Password)));
person1Auth = new TokenResponse(tokenService.generateToken(person1.getUuid()));
person1Auth = new TokenResponseTo(tokenService.generateToken(person1.getUuid()));
person2 = personRepository.save(
new Person()
.setUsername("person2")
.setFullName("Person 2")
.setPassword(passwordEncoder.encode(person2Password)).setFollowing(List.of(person1)));
person2Auth = new TokenResponse(tokenService.generateToken(person2.getUuid()));
person2Auth = new TokenResponseTo(tokenService.generateToken(person2.getUuid()));
person3 = personRepository.save(
new Person()
.setUsername("person3")
.setFullName("Person 3")
.setPassword(passwordEncoder.encode(person3Password))
.setFollowing(List.of(person2, person1)));
person3Auth = new TokenResponse(tokenService.generateToken(person3.getUuid()));
person3Auth = new TokenResponseTo(tokenService.generateToken(person3.getUuid()));
post1 = postRepository.save(new Post().setAuthor(person1).setText("post 1"));
post2 = postRepository.save(new Post().setAuthor(person2).setText("post 2"));

View File

@@ -1,6 +1,6 @@
package com.usatiuk.tjv.y.server.controller;
import com.usatiuk.tjv.y.server.dto.PersonSignupRequest;
import com.usatiuk.tjv.y.server.dto.PersonSignupTo;
import com.usatiuk.tjv.y.server.dto.PersonTo;
import com.usatiuk.tjv.y.server.dto.converters.PersonMapper;
import com.usatiuk.tjv.y.server.repository.PersonRepository;
@@ -21,7 +21,7 @@ public class PersonControllerTest extends DemoDataDbTest {
@Test
void shouldSignUp() {
var response = restTemplate.exchange(addr + "/person", HttpMethod.POST,
new HttpEntity<>(new PersonSignupRequest("usernew", "full name", "pass")),
new HttpEntity<>(new PersonSignupTo("usernew", "full name", "pass")),
PersonTo.class);
Assertions.assertNotNull(response);
@@ -50,6 +50,21 @@ public class PersonControllerTest extends DemoDataDbTest {
Assertions.assertEquals(personToResponse.fullName(), person1.getFullName());
}
@Test
void shouldGetSelf() {
var response = restTemplate.exchange(addr + "/person",
HttpMethod.GET, new HttpEntity<>(createAuthHeaders(person1Auth)), PersonTo.class);
Assertions.assertNotNull(response);
Assertions.assertEquals(HttpStatus.OK, response.getStatusCode());
PersonTo personToResponse = response.getBody();
Assertions.assertNotNull(personToResponse);
Assertions.assertEquals(personToResponse.username(), person1.getUsername());
Assertions.assertEquals(personToResponse.fullName(), person1.getFullName());
}
@Test
void shouldGetFollowers() {
var response = restTemplate.exchange(addr + "/person/followers",

View File

@@ -1,6 +1,6 @@
package com.usatiuk.tjv.y.server.controller;
import com.usatiuk.tjv.y.server.dto.PostCreate;
import com.usatiuk.tjv.y.server.dto.PostCreateTo;
import com.usatiuk.tjv.y.server.dto.PostTo;
import com.usatiuk.tjv.y.server.dto.converters.PostMapper;
import com.usatiuk.tjv.y.server.repository.PostRepository;
@@ -22,16 +22,16 @@ public class PostControllerTest extends DemoDataDbTest {
void shouldNotCreatePostWithoutAuth() {
Long postsBefore = postRepository.count();
var response = restTemplate.exchange(addr + "/post", HttpMethod.POST,
new HttpEntity<>(new PostCreate("test text")), PostTo.class);
new HttpEntity<>(new PostCreateTo("test text")), PostTo.class);
Assertions.assertEquals(HttpStatus.FORBIDDEN, response.getStatusCode());
Assertions.assertEquals(HttpStatus.UNAUTHORIZED, response.getStatusCode());
Assertions.assertEquals(postRepository.count(), postsBefore);
}
@Test
void shouldCreatePost() {
var entity = new HttpEntity<>(new PostCreate("test text"), createAuthHeaders(person1Auth));
var entity = new HttpEntity<>(new PostCreateTo("test text"), createAuthHeaders(person1Auth));
var response = restTemplate.exchange(addr + "/post", HttpMethod.POST,
entity, PostTo.class);

View File

@@ -1,7 +1,7 @@
package com.usatiuk.tjv.y.server.controller;
import com.usatiuk.tjv.y.server.dto.TokenRequest;
import com.usatiuk.tjv.y.server.dto.TokenResponse;
import com.usatiuk.tjv.y.server.dto.TokenRequestTo;
import com.usatiuk.tjv.y.server.dto.TokenResponseTo;
import com.usatiuk.tjv.y.server.service.TokenService;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
@@ -18,12 +18,12 @@ public class TokenControllerTest extends DemoDataDbTest {
@Test
void shouldLogin() {
var response = restTemplate.exchange(addr + "/token", HttpMethod.POST,
new HttpEntity<>(new TokenRequest(person1.getUsername(), person1Password)), TokenResponse.class);
new HttpEntity<>(new TokenRequestTo(person1.getUsername(), person1Password)), TokenResponseTo.class);
Assertions.assertNotNull(response);
Assertions.assertEquals(HttpStatus.OK, response.getStatusCode());
TokenResponse parsedResponse = response.getBody();
TokenResponseTo parsedResponse = response.getBody();
Assertions.assertNotNull(parsedResponse);
Assertions.assertTrue(tokenService.parseToken(parsedResponse.token()).isPresent());