diff --git a/.gitignore b/.gitignore index 8bbe72a8..613d1130 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ node_modules coverage -docs/build \ No newline at end of file +docs/build +.idea \ No newline at end of file diff --git a/README.md b/README.md index f1dab6f8..dda9a07c 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [![Coverage](https://sonarcloud.io/api/project_badges/measure?project=Arquisoft_wiq_en2b&metric=coverage)](https://sonarcloud.io/summary/new_code?id=Arquisoft_wiq_en2b) This is a base repo for the [Software Architecture course](http://arquisoft.github.io/) in [2023/2024 edition](https://arquisoft.github.io/course2324.html). - + ## Contributors | Nombre | UO | |---------------------------------|----------| @@ -15,6 +15,7 @@ This is a base repo for the [Software Architecture course](http://arquisoft.gith | Jorge Joaquín Gancedo Fernández | UO282161 | | Sergio Quintana Fernández | UO288090 | | Diego Villanueva Berros | UO283615 | +| Gonzalo Suárez Losada | UO283928 | This repo is a basic application composed of several components. diff --git a/api/pom.xml b/api/pom.xml index 70e9a137..c16846b8 100644 --- a/api/pom.xml +++ b/api/pom.xml @@ -20,10 +20,22 @@ org.springframework.boot spring-boot-starter-data-jpa + 3.2.2 + + + org.springframework.boot + spring-boot-starter-web + 3.2.2 org.springframework.boot spring-boot-starter-security + 3.2.2 + + + jakarta.validation + jakarta.validation-api + 3.0.2 @@ -46,6 +58,26 @@ spring-security-test test + + com.fasterxml.jackson.core + jackson-annotations + 2.15.2 + + + io.jsonwebtoken + jjwt-api + 0.12.1 + + + io.jsonwebtoken + jjwt-impl + 0.12.1 + + + io.jsonwebtoken + jjwt-jackson + 0.12.1 + diff --git a/api/src/main/java/lab/en2b/quizapi/auth/AuthController.java b/api/src/main/java/lab/en2b/quizapi/auth/AuthController.java new file mode 100644 index 00000000..1a311a0a --- /dev/null +++ b/api/src/main/java/lab/en2b/quizapi/auth/AuthController.java @@ -0,0 +1,34 @@ +package lab.en2b.quizapi.auth; + +import jakarta.validation.Valid; +import lab.en2b.quizapi.auth.dtos.LoginDto; +import lab.en2b.quizapi.auth.dtos.RefreshTokenDto; +import lab.en2b.quizapi.auth.dtos.RegisterDto; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +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; +@RestController +@RequestMapping("/auth") +public class AuthController { + + @Autowired + private AuthService authService; + + @PostMapping("/register") + public ResponseEntity registerUser(@Valid @RequestBody RegisterDto registerRequest){ + return authService.register(registerRequest); + } + + @PostMapping("/login") + public ResponseEntity loginUser(@Valid @RequestBody LoginDto loginRequest){ + return authService.login(loginRequest); + } + + @PostMapping("/refresh-token") + public ResponseEntity refreshToken(@Valid @RequestBody RefreshTokenDto refreshTokenRequest){ + return authService.refreshToken(refreshTokenRequest); + } +} diff --git a/api/src/main/java/lab/en2b/quizapi/auth/AuthService.java b/api/src/main/java/lab/en2b/quizapi/auth/AuthService.java new file mode 100644 index 00000000..82e39000 --- /dev/null +++ b/api/src/main/java/lab/en2b/quizapi/auth/AuthService.java @@ -0,0 +1,59 @@ +package lab.en2b.quizapi.auth; + +import lab.en2b.quizapi.auth.config.UserDetailsImpl; +import lab.en2b.quizapi.auth.dtos.*; +import lab.en2b.quizapi.auth.jwt.JwtUtils; +import lab.en2b.quizapi.commons.exceptions.TokenRefreshException; +import lab.en2b.quizapi.auth.dtos.RefreshTokenResponseDto; +import lab.en2b.quizapi.commons.user.User; +import lab.en2b.quizapi.commons.user.UserService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Set; + +@Service +@RequiredArgsConstructor +public class AuthService { + private final JwtUtils jwtUtils; + private final AuthenticationManager authenticationManager; + private final UserService userService; + /** + * Creates a session for a user. Throws an 401 unauthorized exception otherwise + * @param loginRequest the request containing the login info + * @return a response containing a fresh jwt token and a refresh token + */ + @Transactional + public ResponseEntity login(LoginDto loginRequest){ + Authentication authentication = authenticationManager.authenticate( + new UsernamePasswordAuthenticationToken(loginRequest.getEmail(), loginRequest.getPassword())); + SecurityContextHolder.getContext().setAuthentication(authentication); + UserDetailsImpl userDetails = (UserDetailsImpl) authentication.getPrincipal(); + + return ResponseEntity.ok(new JwtResponseDto( + jwtUtils.generateJwtTokenUserPassword(authentication), + userService.assignNewRefreshToken(userDetails.getId()), + userDetails.getId(), + userDetails.getUsername(), + userDetails.getEmail(), + userDetails.getStringRoles()) + ); + } + + public ResponseEntity register(RegisterDto registerRequest) { + userService.createUser(registerRequest,Set.of("user")); + return ResponseEntity.ok("User registered successfully!"); + } + + public ResponseEntity refreshToken(RefreshTokenDto refreshTokenRequest) { + User user = userService.findByRefreshToken(refreshTokenRequest.getRefreshToken()).orElseThrow(() -> new TokenRefreshException( + "Refresh token is not in database!")); + return ResponseEntity.ok(new RefreshTokenResponseDto(jwtUtils.generateTokenFromEmail(user.getEmail()), user.obtainRefreshIfValid())); + } +} diff --git a/api/src/main/java/lab/en2b/quizapi/auth/config/SecurityConfig.java b/api/src/main/java/lab/en2b/quizapi/auth/config/SecurityConfig.java new file mode 100644 index 00000000..321fb182 --- /dev/null +++ b/api/src/main/java/lab/en2b/quizapi/auth/config/SecurityConfig.java @@ -0,0 +1,84 @@ +package lab.en2b.quizapi.auth.config; + +import lab.en2b.quizapi.auth.jwt.JwtAuthFilter; +import lab.en2b.quizapi.commons.user.UserService; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.Customizer; +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.configurers.AbstractHttpConfigurer; +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.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +import org.springframework.web.filter.CorsFilter; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + @Autowired + public UserService userService; + @Bean + public JwtAuthFilter authenticationJwtTokenFilter() { + return new JwtAuthFilter(); + } + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + @Bean + public CorsFilter corsFilter() { + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + CorsConfiguration config = new CorsConfiguration(); + // Configure CORS settings here + config.setAllowCredentials(true); + config.addAllowedOrigin("*"); + config.addAllowedHeader("*"); + config.addAllowedMethod("*"); + source.registerCorsConfiguration("/**", config); + return new CorsFilter(source); + } + /** + * Security filter used for filtering all petitions, applying cors and asking for authentication among other things + * @param http the http request to filter + * @return the filtered request + * @throws Exception if any problem happens when filtering + */ + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http, AuthenticationManager authenticationManager) throws Exception { + return http + .cors(Customizer.withDefaults()) + .sessionManagement(configuration -> configuration.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(authorize -> authorize + .requestMatchers(HttpMethod.POST,"/auth/**").permitAll() + .anyRequest().authenticated()) + .csrf(AbstractHttpConfigurer::disable) + .authenticationManager(authenticationManager) + .addFilterBefore(authenticationJwtTokenFilter(), UsernamePasswordAuthenticationFilter.class) + .build(); + //TODO: add exception handling + } + + /** + * Builds the authorization manager taking into account password encoding + * @param http the http request to secure + * @return the newly created authentication manager + * @throws Exception if something goes wrong when creating the manager + */ + @Bean + public AuthenticationManager authManager(HttpSecurity http) throws Exception { + AuthenticationManagerBuilder authenticationManagerBuilder = http.getSharedObject(AuthenticationManagerBuilder.class); + authenticationManagerBuilder.userDetailsService(userService).passwordEncoder(passwordEncoder()); + return authenticationManagerBuilder.build(); + } +} diff --git a/api/src/main/java/lab/en2b/quizapi/auth/config/UserDetailsImpl.java b/api/src/main/java/lab/en2b/quizapi/auth/config/UserDetailsImpl.java new file mode 100644 index 00000000..7690b1d2 --- /dev/null +++ b/api/src/main/java/lab/en2b/quizapi/auth/config/UserDetailsImpl.java @@ -0,0 +1,66 @@ +package lab.en2b.quizapi.auth.config; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import lab.en2b.quizapi.commons.user.User; +import lab.en2b.quizapi.commons.user.role.Role; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; + +@Getter +@AllArgsConstructor +public class UserDetailsImpl implements UserDetails { + private Long id; + private String username; + private String email; + @JsonIgnore + private String password; + private Collection authorities; + public static UserDetailsImpl build(User user) { + List authorities = new ArrayList<>(); + for(Role role : user.getRoles()){ + authorities.add(new SimpleGrantedAuthority(role.getName())); + } + return new UserDetailsImpl(user.getId(),user.getUsername() , user.getEmail(), user.getPassword(), authorities); + } + + @Override + public boolean isAccountNonExpired() { + return true; + } + @Override + public boolean isAccountNonLocked() { + return true; + } + @Override + public boolean isCredentialsNonExpired() { + return true; + } + @Override + public boolean isEnabled() { + return true; + } + @Override + public boolean equals(Object o) { + if (this == o) + return true; + if (o == null || getClass() != o.getClass()) + return false; + UserDetailsImpl user = (UserDetailsImpl) o; + return Objects.equals(id, user.id); + } + + public List getStringRoles() { + return getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .collect(Collectors.toList()); + } +} diff --git a/api/src/main/java/lab/en2b/quizapi/auth/dtos/JwtResponseDto.java b/api/src/main/java/lab/en2b/quizapi/auth/dtos/JwtResponseDto.java new file mode 100644 index 00000000..d50c5f83 --- /dev/null +++ b/api/src/main/java/lab/en2b/quizapi/auth/dtos/JwtResponseDto.java @@ -0,0 +1,22 @@ +package lab.en2b.quizapi.auth.dtos; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +@AllArgsConstructor +@NoArgsConstructor +@Getter +public class JwtResponseDto { + private String token; + @JsonProperty("refresh_token") + private String refreshToken; + @JsonProperty("user_id") + private Long userId; + private String username; + private String email; + private List roles; +} diff --git a/api/src/main/java/lab/en2b/quizapi/auth/dtos/LoginDto.java b/api/src/main/java/lab/en2b/quizapi/auth/dtos/LoginDto.java new file mode 100644 index 00000000..5e7a0ec5 --- /dev/null +++ b/api/src/main/java/lab/en2b/quizapi/auth/dtos/LoginDto.java @@ -0,0 +1,16 @@ +package lab.en2b.quizapi.auth.dtos; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.NonNull; + +@NoArgsConstructor +@AllArgsConstructor +@Data +public class LoginDto { + @NonNull + private String email; + @NonNull + private String password; +} diff --git a/api/src/main/java/lab/en2b/quizapi/auth/dtos/RefreshTokenDto.java b/api/src/main/java/lab/en2b/quizapi/auth/dtos/RefreshTokenDto.java new file mode 100644 index 00000000..f327ae8a --- /dev/null +++ b/api/src/main/java/lab/en2b/quizapi/auth/dtos/RefreshTokenDto.java @@ -0,0 +1,16 @@ +package lab.en2b.quizapi.auth.dtos; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.NonNull; + +@AllArgsConstructor +@NoArgsConstructor +@Data +public class RefreshTokenDto { + @NonNull + @JsonProperty("refresh_token") + private String refreshToken; +} diff --git a/api/src/main/java/lab/en2b/quizapi/auth/dtos/RefreshTokenResponseDto.java b/api/src/main/java/lab/en2b/quizapi/auth/dtos/RefreshTokenResponseDto.java new file mode 100644 index 00000000..4af19ee2 --- /dev/null +++ b/api/src/main/java/lab/en2b/quizapi/auth/dtos/RefreshTokenResponseDto.java @@ -0,0 +1,20 @@ +package lab.en2b.quizapi.auth.dtos; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class RefreshTokenResponseDto { + + private String token; + @JsonProperty("refresh_token") + private String refreshToken; + + public RefreshTokenResponseDto(String accessToken, String refreshToken) { + this.token = accessToken; + this.refreshToken = refreshToken; + } + +} diff --git a/api/src/main/java/lab/en2b/quizapi/auth/dtos/RegisterDto.java b/api/src/main/java/lab/en2b/quizapi/auth/dtos/RegisterDto.java new file mode 100644 index 00000000..389b8583 --- /dev/null +++ b/api/src/main/java/lab/en2b/quizapi/auth/dtos/RegisterDto.java @@ -0,0 +1,18 @@ +package lab.en2b.quizapi.auth.dtos; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.NonNull; + +@AllArgsConstructor +@NoArgsConstructor +@Data +public class RegisterDto { + @NonNull + private String email; + @NonNull + private String username; + @NonNull + private String password; +} diff --git a/api/src/main/java/lab/en2b/quizapi/auth/jwt/JwtAuthFilter.java b/api/src/main/java/lab/en2b/quizapi/auth/jwt/JwtAuthFilter.java new file mode 100644 index 00000000..9f056081 --- /dev/null +++ b/api/src/main/java/lab/en2b/quizapi/auth/jwt/JwtAuthFilter.java @@ -0,0 +1,59 @@ +package lab.en2b.quizapi.auth.jwt; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lab.en2b.quizapi.commons.user.UserService; +import lombok.NonNull; +import org.springframework.beans.factory.annotation.Autowired; +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.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +@Component +public class JwtAuthFilter extends OncePerRequestFilter { + + @Autowired + private JwtUtils jwtUtils; + + @Autowired + private UserService userDetailsService; + + @Override + protected void doFilterInternal(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response, + @NonNull FilterChain filterChain) throws ServletException, IOException { + String token = parseJwt(request); + String email = null; + if(token != null){ + email = jwtUtils.getSubjectFromJwtToken(token); + } + + if ( email != null && SecurityContextHolder.getContext().getAuthentication() == null && isValidJwt(token)) { + UserDetails userDetails = userDetailsService.loadUserByUsername(email); + // this invokes UsernamePasswordAuthenticationToken, although it uses email as subject not username + UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(userDetails, + null, userDetails.getAuthorities()); + authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + SecurityContextHolder.getContext().setAuthentication(authToken); + } + filterChain.doFilter(request, response); + } + + private boolean isValidJwt(String token) { + return token != null && jwtUtils.validateJwtToken(token); + } + + private String parseJwt(HttpServletRequest request) { + String headerAuth = request.getHeader("Authorization"); + if (StringUtils.hasText(headerAuth) && headerAuth.startsWith("Bearer ")) { + return headerAuth.substring(7); + } + return null; + } +} + diff --git a/api/src/main/java/lab/en2b/quizapi/auth/jwt/JwtUtils.java b/api/src/main/java/lab/en2b/quizapi/auth/jwt/JwtUtils.java new file mode 100644 index 00000000..432b0813 --- /dev/null +++ b/api/src/main/java/lab/en2b/quizapi/auth/jwt/JwtUtils.java @@ -0,0 +1,86 @@ +package lab.en2b.quizapi.auth.jwt; + +import io.jsonwebtoken.*; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import io.jsonwebtoken.security.SignatureException; +import lab.en2b.quizapi.auth.config.UserDetailsImpl; +import lombok.extern.log4j.Log4j2; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.util.Date; +import java.util.function.Function; + +@Component +@Log4j2 +public class JwtUtils { + + //MUST BE SET AS ENVIRONMENT VARIABLE + @Value("${JWT_SECRET}") + private String JWT_SECRET; + @Value("${JWT_EXPIRATION_MS}") + private Long JWT_EXPIRATION_MS; + + + public String generateJwtTokenUserPassword(Authentication authentication) { + UserDetailsImpl userPrincipal = (UserDetailsImpl) authentication.getPrincipal(); + + return Jwts.builder() + .subject(userPrincipal.getEmail()) + .issuedAt(new Date()) + .expiration(new Date((new Date()).getTime() + JWT_EXPIRATION_MS)) + .signWith(getSignInKey()) + .compact(); + } + public boolean validateJwtToken(String authToken) { + try { + Jwts.parser().verifyWith(getSignInKey()).build().parseSignedClaims(authToken); + return true; + } catch (SignatureException e) { + log.error("Invalid JWT signature: {}", e.getMessage()); + } catch (MalformedJwtException e) { + log.error("Invalid JWT token: {}", e.getMessage()); + } catch (ExpiredJwtException e) { + log.error("JWT token is expired: {}", e.getMessage()); + } catch (UnsupportedJwtException e) { + log.error("JWT token is unsupported: {}", e.getMessage()); + } catch (IllegalArgumentException e) { + log.error("JWT claims string is empty: {}", e.getMessage()); + } + return false; + } + private Claims extractAllClaims(String token){ + return Jwts.parser() + .verifyWith(getSignInKey()) + .build() + .parseSignedClaims(token) + .getPayload(); + } + public T extractClaim(String token, Function claimsResolver){ + final Claims claims = extractAllClaims(token); + return claimsResolver.apply(claims); + } + public String getSubjectFromJwtToken(String token) { + if(validateJwtToken(token)){ + return extractClaim(token, Claims::getSubject); + }else{ + throw new IllegalArgumentException(); + } + } + private SecretKey getSignInKey(){ + byte[] keyBytes = Decoders.BASE64.decode(JWT_SECRET); + return Keys.hmacShaKeyFor(keyBytes); + } + + public String generateTokenFromEmail(String email) { + return Jwts.builder() + .subject(email) + .issuedAt(new Date()) + .expiration(new Date((new Date()).getTime() + JWT_EXPIRATION_MS)) + .signWith(getSignInKey()) + .compact(); + } +} diff --git a/api/src/main/java/lab/en2b/quizapi/commons/exceptions/CustomControllerAdvice.java b/api/src/main/java/lab/en2b/quizapi/commons/exceptions/CustomControllerAdvice.java new file mode 100644 index 00000000..7032fbf4 --- /dev/null +++ b/api/src/main/java/lab/en2b/quizapi/commons/exceptions/CustomControllerAdvice.java @@ -0,0 +1,61 @@ +package lab.en2b.quizapi.commons.exceptions; + +import java.util.NoSuchElementException; + +import lombok.extern.log4j.Log4j2; +import org.springframework.core.annotation.Order; +import org.springframework.core.Ordered; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.InternalAuthenticationServiceException; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; + +@ControllerAdvice +@Log4j2 +@Order(Ordered.HIGHEST_PRECEDENCE) +public class CustomControllerAdvice extends ResponseEntityExceptionHandler { + @ExceptionHandler(NoSuchElementException.class) + public ResponseEntity handleNoSuchElementException(NoSuchElementException exception){ + log.error(exception.getMessage(),exception); + return new ResponseEntity<>(exception.getMessage(),HttpStatus.NOT_FOUND); + } + + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity handleIllegalArgumentException(IllegalArgumentException exception){ + log.error(exception.getMessage(),exception); + return new ResponseEntity<>(exception.getMessage(),HttpStatus.BAD_REQUEST); + } + + @ExceptionHandler(BadCredentialsException.class) + public ResponseEntity handleBadCredentialsException(BadCredentialsException exception){ + log.error(exception.getMessage(),exception); + return new ResponseEntity<>(exception.getMessage(),HttpStatus.UNAUTHORIZED); + } + + @ExceptionHandler(AccessDeniedException.class) + public ResponseEntity handleAccessDeniedException(AccessDeniedException exception){ + log.error(exception.getMessage(),exception); + return new ResponseEntity<>(exception.getMessage(), HttpStatus.FORBIDDEN); + } + + @ExceptionHandler(TokenRefreshException.class) + public ResponseEntity handleTokenRefreshException(TokenRefreshException exception) { + log.error(exception.getMessage(),exception); + return new ResponseEntity<>(exception.getMessage(),HttpStatus.FORBIDDEN); + } + @ExceptionHandler(InternalAuthenticationServiceException.class) + public ResponseEntity handleInternalAuthenticationServiceException(InternalAuthenticationServiceException exception) { + log.error(exception.getMessage(),exception); + return new ResponseEntity<>(exception.getMessage(),HttpStatus.FORBIDDEN); + } + @ExceptionHandler(Exception.class) + public ResponseEntity handleException(Exception exception){ + log.error(exception.getMessage(),exception); + return new ResponseEntity<>(exception.getMessage(),HttpStatus.INTERNAL_SERVER_ERROR); + } + +} diff --git a/api/src/main/java/lab/en2b/quizapi/commons/exceptions/TokenRefreshException.java b/api/src/main/java/lab/en2b/quizapi/commons/exceptions/TokenRefreshException.java new file mode 100644 index 00000000..3271e6cf --- /dev/null +++ b/api/src/main/java/lab/en2b/quizapi/commons/exceptions/TokenRefreshException.java @@ -0,0 +1,7 @@ +package lab.en2b.quizapi.commons.exceptions; + +public class TokenRefreshException extends RuntimeException{ + public TokenRefreshException(String message) { + super(message); + } +} diff --git a/api/src/main/java/lab/en2b/quizapi/commons/user/User.java b/api/src/main/java/lab/en2b/quizapi/commons/user/User.java new file mode 100644 index 00000000..56d64935 --- /dev/null +++ b/api/src/main/java/lab/en2b/quizapi/commons/user/User.java @@ -0,0 +1,76 @@ +package lab.en2b.quizapi.commons.user; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import jakarta.persistence.*; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lab.en2b.quizapi.commons.exceptions.TokenRefreshException; +import lab.en2b.quizapi.commons.user.role.Role; +import lombok.*; + +import java.time.Instant; +import java.util.Set; + +@Entity +@Table( name = "users", + uniqueConstraints = { + @UniqueConstraint(columnNames = "email"), + @UniqueConstraint(columnNames = "username") + }) +@RequiredArgsConstructor +@NoArgsConstructor +@Getter +@Setter +@AllArgsConstructor +@Builder +public class User { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Setter(AccessLevel.NONE) + private Long id; + + @NotBlank + @Size(max=255) + @NonNull + private String username; + + @NotBlank + @Size(max = 255) + @Email + @NonNull + private String email; + + @NotBlank + @Size(max = 255) + @NonNull + private String password; + + @Column(name = "refresh_token",unique = true) + @Size(max = 255) + private String refreshToken; + + @Column(name="refresh_expiration") + private Instant refreshExpiration; + + @NotNull + @ManyToMany(fetch = FetchType.EAGER) + @JoinTable(name="users_roles", + joinColumns= + @JoinColumn(name="user_id", referencedColumnName="id"), + inverseJoinColumns= + @JoinColumn(name="role_id", referencedColumnName="id") + ) + @JsonIgnoreProperties({"hibernateLazyInitializer", "handler", "permissions"}) + @JsonProperty("role") + private Set roles; + + public String obtainRefreshIfValid() { + if(getRefreshExpiration() == null || getRefreshExpiration().compareTo(Instant.now()) < 0){ + throw new TokenRefreshException( "Invalid refresh token. Please make a new login request"); + } + return getRefreshToken(); + } +} diff --git a/api/src/main/java/lab/en2b/quizapi/commons/user/UserRepository.java b/api/src/main/java/lab/en2b/quizapi/commons/user/UserRepository.java new file mode 100644 index 00000000..780f15cf --- /dev/null +++ b/api/src/main/java/lab/en2b/quizapi/commons/user/UserRepository.java @@ -0,0 +1,17 @@ +package lab.en2b.quizapi.commons.user; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface UserRepository extends JpaRepository { + Optional findByEmail(String email); + + Boolean existsByUsername(String username); + + Boolean existsByEmail(String email); + + Optional findByRefreshToken(String refreshToken); +} diff --git a/api/src/main/java/lab/en2b/quizapi/commons/user/UserService.java b/api/src/main/java/lab/en2b/quizapi/commons/user/UserService.java new file mode 100644 index 00000000..d6ed64d9 --- /dev/null +++ b/api/src/main/java/lab/en2b/quizapi/commons/user/UserService.java @@ -0,0 +1,55 @@ +package lab.en2b.quizapi.commons.user; + +import lab.en2b.quizapi.auth.config.UserDetailsImpl; +import lab.en2b.quizapi.auth.dtos.RegisterDto; +import lab.en2b.quizapi.commons.user.role.RoleRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class UserService implements UserDetailsService { + private final UserRepository userRepository; + private final RoleRepository roleRepository; + @Value("${REFRESH_TOKEN_DURATION_MS}") + private Long REFRESH_TOKEN_DURATION_MS; + @Override + public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException { + return UserDetailsImpl.build(userRepository.findByEmail(email).orElseThrow()); + } + public void createUser(RegisterDto registerRequest, Set roleNames){ + if (userRepository.existsByEmail(registerRequest.getEmail()) || userRepository.existsByUsername(registerRequest.getUsername())) { + throw new IllegalArgumentException("Error: email is already in use!"); + } + + userRepository.save(User.builder() + .username(registerRequest.getUsername()) + .email(registerRequest.getEmail()) + .password(new BCryptPasswordEncoder().encode(registerRequest.getPassword())) + .roles(roleNames.stream().map( roleName -> roleRepository.findByName(roleName).orElseThrow()).collect(Collectors.toSet())) + .build()); + } + + public Optional findByRefreshToken(String refreshToken) { + return userRepository.findByRefreshToken(refreshToken); + } + + public String assignNewRefreshToken(Long id) { + User user = userRepository.findById(id).orElseThrow(); + user.setRefreshToken(UUID.randomUUID().toString()); + user.setRefreshExpiration(Instant.now().plusMillis(REFRESH_TOKEN_DURATION_MS)); + userRepository.save(user); + return user.getRefreshToken(); + } +} diff --git a/api/src/main/java/lab/en2b/quizapi/commons/user/role/Role.java b/api/src/main/java/lab/en2b/quizapi/commons/user/role/Role.java new file mode 100644 index 00000000..8e828ff1 --- /dev/null +++ b/api/src/main/java/lab/en2b/quizapi/commons/user/role/Role.java @@ -0,0 +1,34 @@ +package lab.en2b.quizapi.commons.user.role; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import jakarta.persistence.*; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lab.en2b.quizapi.commons.user.User; +import lombok.*; + +import java.util.Set; + +@Entity +@Table(name = "roles") +@NoArgsConstructor +@RequiredArgsConstructor +@AllArgsConstructor +@Setter +@Getter +@Builder +public class Role { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Setter(AccessLevel.NONE) + private Long id; + + @NonNull + @NotBlank + @Size(max=255) + private String name; + + @ManyToMany(mappedBy ="roles") + @JsonIgnoreProperties({"hibernateLazyInitializer", "handler", "roles"}) + private Set users; +} diff --git a/api/src/main/java/lab/en2b/quizapi/commons/user/role/RoleRepository.java b/api/src/main/java/lab/en2b/quizapi/commons/user/role/RoleRepository.java new file mode 100644 index 00000000..84c7a7a2 --- /dev/null +++ b/api/src/main/java/lab/en2b/quizapi/commons/user/role/RoleRepository.java @@ -0,0 +1,12 @@ +package lab.en2b.quizapi.commons.user.role; + + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface RoleRepository extends JpaRepository { + Optional findByName(String roleName); +} diff --git a/api/src/main/java/lab/en2b/quizapi/questions/question/QuestionController.java b/api/src/main/java/lab/en2b/quizapi/questions/question/QuestionController.java new file mode 100644 index 00000000..1c1e7127 --- /dev/null +++ b/api/src/main/java/lab/en2b/quizapi/questions/question/QuestionController.java @@ -0,0 +1,14 @@ +package lab.en2b.quizapi.questions.question; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/questions") +public class QuestionController { + @GetMapping("/dummy") + private String getDummyQuestion(){ + return "Who the hell is Steve Jobs?"; + } +} diff --git a/api/src/main/resources/application.properties b/api/src/main/resources/application.properties index 8b137891..8419350a 100644 --- a/api/src/main/resources/application.properties +++ b/api/src/main/resources/application.properties @@ -1 +1,6 @@ - +JWT_EXPIRATION_MS=86400000 +REFRESH_TOKEN_DURATION_MS=86400000 +spring.jpa.hibernate.ddl-auto=update +spring.datasource.url=${DATABASE_URL} +spring.datasource.username=${DATABASE_USER} +spring.datasource.password=${DATABASE_PASSWORD}