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