diff --git a/backend/sportsmatch/src/main/java/com/sportsmatch/SportsmatchApplication.java b/backend/sportsmatch/src/main/java/com/sportsmatch/SportsmatchApplication.java index ae1fbce3..826ce3cf 100644 --- a/backend/sportsmatch/src/main/java/com/sportsmatch/SportsmatchApplication.java +++ b/backend/sportsmatch/src/main/java/com/sportsmatch/SportsmatchApplication.java @@ -1,6 +1,5 @@ package com.sportsmatch; -import io.swagger.v3.oas.annotations.OpenAPIDefinition; import com.sportsmatch.models.*; import com.sportsmatch.repositories.*; import lombok.AllArgsConstructor; @@ -16,7 +15,6 @@ @AllArgsConstructor @SpringBootApplication -@OpenAPIDefinition @EnableWebMvc public class SportsmatchApplication implements CommandLineRunner { diff --git a/backend/sportsmatch/src/main/java/com/sportsmatch/auth/AuthService.java b/backend/sportsmatch/src/main/java/com/sportsmatch/auth/AuthService.java index 8f08e052..883ddf5b 100644 --- a/backend/sportsmatch/src/main/java/com/sportsmatch/auth/AuthService.java +++ b/backend/sportsmatch/src/main/java/com/sportsmatch/auth/AuthService.java @@ -6,9 +6,11 @@ import com.sportsmatch.models.User; import com.sportsmatch.repositories.UserRepository; import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; @Service @RequiredArgsConstructor @@ -21,10 +23,13 @@ public class AuthService { public void register(AuthRequestDTO authRequestDTO) { User user = userMapper.registerToUser(authRequestDTO); + if (userRepository.existsByEmail(user.getEmail())) { + throw new ResponseStatusException(HttpStatus.CONFLICT); + } userRepository.save(user); } - public AuthResponseDTO authenticate(AuthRequestDTO authRequestDTO) { + public AuthResponseDTO login(AuthRequestDTO authRequestDTO) { authenticationManager.authenticate( new UsernamePasswordAuthenticationToken( authRequestDTO.getEmail(), authRequestDTO.getPassword())); diff --git a/backend/sportsmatch/src/main/java/com/sportsmatch/auth/JwtAuthFilter.java b/backend/sportsmatch/src/main/java/com/sportsmatch/auth/JwtAuthFilter.java index c9491267..e1c8a3fa 100644 --- a/backend/sportsmatch/src/main/java/com/sportsmatch/auth/JwtAuthFilter.java +++ b/backend/sportsmatch/src/main/java/com/sportsmatch/auth/JwtAuthFilter.java @@ -1,9 +1,11 @@ package com.sportsmatch.auth; +import com.sportsmatch.repositories.TokenRepository; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; import lombok.NonNull; import lombok.RequiredArgsConstructor; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; @@ -14,14 +16,13 @@ import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; -import java.io.IOException; - @Component @RequiredArgsConstructor public class JwtAuthFilter extends OncePerRequestFilter { private final JwtService jwtService; private final UserDetailsService userDetailsService; + private final TokenRepository tokenRepository; @Override protected void doFilterInternal( @@ -30,31 +31,41 @@ protected void doFilterInternal( @NonNull FilterChain filterChain) throws ServletException, IOException { - final String authHeader = request.getHeader("Authorization"); // header that contains JWT Token - final String jwt; - final String userEmail; + final String authHeader = request.getHeader("Authorization"); - if (authHeader == null - || !authHeader.startsWith("Bearer ")) { // check header if contains JWT Token + if (isBearerTokenNotPresent(authHeader)) { filterChain.doFilter(request, response); return; } - jwt = authHeader.substring(7); // takes JWT Token from header, index from "Bearer " - userEmail = jwtService.extractUserName(jwt); // takes userEmail from JWT Token + final String jwt = authHeader.substring(7); + final String userEmail = jwtService.extractUserName(jwt); - if (userEmail != null - && SecurityContextHolder.getContext().getAuthentication() - == null) { // check if user is authenticated + if (isUserAuthenticated(userEmail)) { UserDetails userDetails = this.userDetailsService.loadUserByUsername(userEmail); - if (jwtService.isTokenValid(jwt, userDetails)) { - UsernamePasswordAuthenticationToken authToken = - new UsernamePasswordAuthenticationToken( - userDetails, null, userDetails.getAuthorities()); - authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); - SecurityContextHolder.getContext().setAuthentication(authToken); + if (jwtService.isTokenValid(jwt, userDetails) && !tokenRepository.existsByToken(jwt)) { + updateSecurityContext(request, userDetails); } } filterChain.doFilter(request, response); } + + public boolean isBearerTokenNotPresent(String authHeader) { + if (authHeader == null) { + return true; + } + String[] tokenParts = authHeader.split(" "); + return !authHeader.startsWith("Bearer ") || tokenParts.length != 2; + } + + public boolean isUserAuthenticated(String userEmail) { + return userEmail != null && SecurityContextHolder.getContext().getAuthentication() == null; + } + + public void updateSecurityContext(HttpServletRequest request, UserDetails userDetails) { + UsernamePasswordAuthenticationToken authToken = + new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); + authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + SecurityContextHolder.getContext().setAuthentication(authToken); + } } diff --git a/backend/sportsmatch/src/main/java/com/sportsmatch/auth/JwtService.java b/backend/sportsmatch/src/main/java/com/sportsmatch/auth/JwtService.java index d95efebd..1c56f731 100644 --- a/backend/sportsmatch/src/main/java/com/sportsmatch/auth/JwtService.java +++ b/backend/sportsmatch/src/main/java/com/sportsmatch/auth/JwtService.java @@ -4,15 +4,14 @@ import io.jsonwebtoken.Jwts; import io.jsonwebtoken.io.Decoders; import io.jsonwebtoken.security.Keys; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.security.core.userdetails.UserDetails; -import org.springframework.stereotype.Service; - -import javax.crypto.SecretKey; import java.util.Date; import java.util.HashMap; import java.util.Map; import java.util.function.Function; +import javax.crypto.SecretKey; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Service; @Service public class JwtService { @@ -24,28 +23,21 @@ public String extractUserName(String token) { return extractClaim(token, Claims::getSubject); } - public T extractClaim( - String token, Function claimsResolver) { // Takes Single Claim from JWT Token + public T extractClaim(String token, Function claimsResolver) { final Claims claims = extractAllClaims(token); return claimsResolver.apply(claims); } - public String generateToken( - UserDetails userDetails) { // Generates JWT Token with only userDetails + public String generateToken(UserDetails userDetails) { return generateToken(new HashMap<>(), userDetails); } - public String generateToken( - Map extraClaims, - UserDetails userDetails) { // Generates JWT Token with additional Claims + public String generateToken(Map extraClaims, UserDetails userDetails) { return Jwts.builder() .claims(extraClaims) .subject(userDetails.getUsername()) .issuedAt(new Date(System.currentTimeMillis())) - .expiration( - new Date( - System.currentTimeMillis() - + 1000 * 60 * 60 * 24)) // JWT Token valid 24h from time issued + .expiration(new Date(System.currentTimeMillis() + 1000 * 60 * 60 * 24)) .signWith(getVerificationKey(), Jwts.SIG.HS256) .compact(); } @@ -63,7 +55,7 @@ private Date extractExpiration(String token) { return extractClaim(token, Claims::getExpiration); } - private Claims extractAllClaims(String token) { // Takes all the claims from JWT Token + private Claims extractAllClaims(String token) { return Jwts.parser() .verifyWith(getVerificationKey()) .build() @@ -71,7 +63,7 @@ private Claims extractAllClaims(String token) { // Takes all the claims from JWT .getPayload(); } - private SecretKey getVerificationKey() { // Sign JWT Token base from secret + private SecretKey getVerificationKey() { byte[] keyBytes = Decoders.BASE64URL.decode(SECRET_KEY); return Keys.hmacShaKeyFor(keyBytes); } diff --git a/backend/sportsmatch/src/main/java/com/sportsmatch/auth/LogoutService.java b/backend/sportsmatch/src/main/java/com/sportsmatch/auth/LogoutService.java new file mode 100644 index 00000000..16c29dc4 --- /dev/null +++ b/backend/sportsmatch/src/main/java/com/sportsmatch/auth/LogoutService.java @@ -0,0 +1,46 @@ +package com.sportsmatch.auth; + +import com.sportsmatch.models.Token; +import com.sportsmatch.models.TokenType; +import com.sportsmatch.models.User; +import com.sportsmatch.repositories.TokenRepository; +import com.sportsmatch.repositories.UserRepository; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.logout.LogoutHandler; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class LogoutService implements LogoutHandler { + + private final JwtAuthFilter authFilter; + private final TokenRepository tokenRepository; + private final UserRepository userRepository; + private final JwtService jwtService; + + @Override + public void logout( + HttpServletRequest request, HttpServletResponse response, Authentication authentication) { + + final String authHeader = request.getHeader("Authorization"); + final String jwt = authHeader.substring(7); + final String userEmail = jwtService.extractUserName(jwt); + Optional user = userRepository.findByEmail(userEmail); + + if (authFilter.isBearerTokenNotPresent(authHeader) || user.isEmpty()) { + return; + } + + tokenRepository.save( + Token.builder() + .token(jwt) + .user(user.get()) + .tokenType(TokenType.BEARER) + .isValid(false) + .build()); + } +} diff --git a/backend/sportsmatch/src/main/java/com/sportsmatch/auth/SecurityConfig.java b/backend/sportsmatch/src/main/java/com/sportsmatch/auth/SecurityConfig.java index 9cc7b05e..ecf69c55 100644 --- a/backend/sportsmatch/src/main/java/com/sportsmatch/auth/SecurityConfig.java +++ b/backend/sportsmatch/src/main/java/com/sportsmatch/auth/SecurityConfig.java @@ -1,5 +1,7 @@ package com.sportsmatch.auth; +import static org.springframework.security.config.http.SessionCreationPolicy.STATELESS; + import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -8,10 +10,10 @@ import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer; +import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; - -import static org.springframework.security.config.http.SessionCreationPolicy.STATELESS; +import org.springframework.security.web.authentication.logout.LogoutHandler; @Configuration @EnableWebSecurity @@ -20,24 +22,27 @@ public class SecurityConfig { private static final String[] WHITE_LIST_URL = { "/api/v1/auth/**", "/h2-console/**", "/v3/api-docs", "/v3/api-docs/**", "/swagger-ui/**" - // add endpoints that are not authenticated }; private final JwtAuthFilter jwtAuthFilter; private final AuthenticationProvider authenticationProvider; + private final LogoutHandler logoutHandler; @Bean - public SecurityFilterChain securityFilterChain(HttpSecurity http) - throws Exception { // set which endpoints are authenticated and not + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http.csrf(AbstractHttpConfigurer::disable) - .headers( - h -> - h.frameOptions( - HeadersConfigurer.FrameOptionsConfig::disable)) // remove in production + .headers(h -> h.frameOptions(HeadersConfigurer.FrameOptionsConfig::disable)) .authorizeHttpRequests( r -> r.requestMatchers(WHITE_LIST_URL).permitAll().anyRequest().authenticated()) .sessionManagement(s -> s.sessionCreationPolicy(STATELESS)) .authenticationProvider(authenticationProvider) - .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class); + .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class) + .logout( + l -> + l.logoutUrl("/api/v1/auth/logout") + .addLogoutHandler(logoutHandler) + .logoutSuccessHandler( + ((request, response, authentication) -> + SecurityContextHolder.clearContext()))); return http.build(); } } diff --git a/backend/sportsmatch/src/main/java/com/sportsmatch/configs/OpenApiConfig.java b/backend/sportsmatch/src/main/java/com/sportsmatch/configs/OpenApiConfig.java new file mode 100644 index 00000000..dd96d9e8 --- /dev/null +++ b/backend/sportsmatch/src/main/java/com/sportsmatch/configs/OpenApiConfig.java @@ -0,0 +1,34 @@ +package com.sportsmatch.configs; + +import io.swagger.v3.oas.annotations.OpenAPIDefinition; +import io.swagger.v3.oas.annotations.enums.SecuritySchemeIn; +import io.swagger.v3.oas.annotations.enums.SecuritySchemeType; +import io.swagger.v3.oas.annotations.info.Contact; +import io.swagger.v3.oas.annotations.info.Info; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.security.SecurityScheme; +import io.swagger.v3.oas.annotations.servers.Server; +import org.springframework.context.annotation.Configuration; + +@Configuration +@OpenAPIDefinition( + info = + @Info( + contact = + @Contact( + name = "Sportsmingle", + email = "hello@sportmingle.com", + url = "sportsmingle.com"), + description = "Sport event scheduling and matchmaking", + title = "Sportsmingle", + version = "v1"), + servers = {@Server(description = "Local ENV", url = "http://localhost:8080")}, + security = {@SecurityRequirement(name = "bearerAuth")}) +@SecurityScheme( + name = "bearerAuth", + description = "JWT auth description", + scheme = "bearer", + type = SecuritySchemeType.HTTP, + bearerFormat = "JWT", + in = SecuritySchemeIn.HEADER) +public class OpenApiConfig {} diff --git a/backend/sportsmatch/src/main/java/com/sportsmatch/controllers/APIController.java b/backend/sportsmatch/src/main/java/com/sportsmatch/controllers/APIController.java index 17fc661e..d262770d 100644 --- a/backend/sportsmatch/src/main/java/com/sportsmatch/controllers/APIController.java +++ b/backend/sportsmatch/src/main/java/com/sportsmatch/controllers/APIController.java @@ -1,6 +1,7 @@ package com.sportsmatch.controllers; import com.sportsmatch.models.User; +import io.swagger.v3.oas.annotations.tags.Tag; import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; @@ -11,6 +12,7 @@ public class APIController { @GetMapping("/hello") + @Tag(name = "ex.secured endpoint") public String hello(Authentication authentication) { User user = (User) authentication.getPrincipal(); return "Welcome " + user.getName() + " to Secured Endpoint "; diff --git a/backend/sportsmatch/src/main/java/com/sportsmatch/controllers/AuthController.java b/backend/sportsmatch/src/main/java/com/sportsmatch/controllers/AuthController.java index dcd6b967..1a07ea48 100644 --- a/backend/sportsmatch/src/main/java/com/sportsmatch/controllers/AuthController.java +++ b/backend/sportsmatch/src/main/java/com/sportsmatch/controllers/AuthController.java @@ -3,6 +3,8 @@ import com.sportsmatch.auth.AuthService; import com.sportsmatch.dtos.AuthRequestDTO; import com.sportsmatch.services.ValidationService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.AllArgsConstructor; import org.springframework.http.ResponseEntity; @@ -11,6 +13,7 @@ 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.server.ResponseStatusException; @RestController @AllArgsConstructor @@ -20,40 +23,34 @@ public class AuthController { private final AuthService authService; private final ValidationService validationService; - /** - * Registers a user based on the provided credentials. - * - * @param authRequestDTO The authentication request containing user details. - * @param bindingResult The result of the validation process. - * @return ResponseEntity indicating the success or failure of the registration. Returns a 400 Bad - * Request with validation errors List<String> if input is invalid. Returns a 200 OK - * if registration is successful. - */ @PostMapping("/register") + @Tag(name = "Register") + @Operation( + summary = "Register new user", + description = "Register a new user by providing their email and username.") public ResponseEntity register( @RequestBody @Valid AuthRequestDTO authRequestDTO, BindingResult bindingResult) { if (bindingResult.hasErrors()) { return ResponseEntity.badRequest().body(validationService.getAllErrors(bindingResult)); } - authService.register(authRequestDTO); - return ResponseEntity.ok().build(); + try { + authService.register(authRequestDTO); + return ResponseEntity.ok().build(); + } catch (ResponseStatusException e) { + return ResponseEntity.status(e.getStatusCode()).build(); + } } - /** - * Authenticates a user based on the provided credentials. - * - * @param authRequestDTO The authentication request containing user credentials. - * @param bindingResult The result of the validation process. - * @return ResponseEntity indicating the success or failure of the authentication. Returns a 400 - * Bad Request with validation errors if input is invalid. Returns a 200 OK with - * authentication details if authentication is successful. - */ - @PostMapping("/authenticate") - public ResponseEntity authenticate( + @PostMapping("/login") + @Tag(name = "Login") + @Operation( + summary = "Login user", + description = "Login a user by providing their email and username.") + public ResponseEntity login( @RequestBody @Valid AuthRequestDTO authRequestDTO, BindingResult bindingResult) { if (bindingResult.hasErrors()) { return ResponseEntity.badRequest().body(validationService.getAllErrors(bindingResult)); } - return ResponseEntity.ok(authService.authenticate(authRequestDTO)); + return ResponseEntity.ok(authService.login(authRequestDTO)); } } diff --git a/backend/sportsmatch/src/main/java/com/sportsmatch/dtos/AuthRequestDTO.java b/backend/sportsmatch/src/main/java/com/sportsmatch/dtos/AuthRequestDTO.java index 14e43cfc..7716c85b 100644 --- a/backend/sportsmatch/src/main/java/com/sportsmatch/dtos/AuthRequestDTO.java +++ b/backend/sportsmatch/src/main/java/com/sportsmatch/dtos/AuthRequestDTO.java @@ -12,18 +12,11 @@ @JsonInclude(JsonInclude.Include.NON_NULL) public class AuthRequestDTO { - private Long id; - - @NotNull + @NotNull(message = "Email address is required.") @Email(message = "Please provide a valid email address") private String email; @NotBlank(message = "Password cannot be blank") @NotNull(message = "Please provide a password") private String password; - - private String name; - private String gender; - private String role; - private String dateOfBirth; } diff --git a/backend/sportsmatch/src/main/java/com/sportsmatch/models/Token.java b/backend/sportsmatch/src/main/java/com/sportsmatch/models/Token.java new file mode 100644 index 00000000..071847b0 --- /dev/null +++ b/backend/sportsmatch/src/main/java/com/sportsmatch/models/Token.java @@ -0,0 +1,25 @@ +package com.sportsmatch.models; + +import jakarta.persistence.*; +import lombok.*; + +@Setter +@Getter +@Builder +@Entity +@NoArgsConstructor +@AllArgsConstructor +public class Token { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String token; + private Boolean isValid; + + @Enumerated(EnumType.STRING) + private TokenType tokenType; + + @ManyToOne private User user; +} diff --git a/backend/sportsmatch/src/main/java/com/sportsmatch/models/TokenType.java b/backend/sportsmatch/src/main/java/com/sportsmatch/models/TokenType.java new file mode 100644 index 00000000..f173e56d --- /dev/null +++ b/backend/sportsmatch/src/main/java/com/sportsmatch/models/TokenType.java @@ -0,0 +1,5 @@ +package com.sportsmatch.models; + +public enum TokenType { + BEARER +} diff --git a/backend/sportsmatch/src/main/java/com/sportsmatch/models/User.java b/backend/sportsmatch/src/main/java/com/sportsmatch/models/User.java index 9eaa003c..5573ec60 100644 --- a/backend/sportsmatch/src/main/java/com/sportsmatch/models/User.java +++ b/backend/sportsmatch/src/main/java/com/sportsmatch/models/User.java @@ -48,6 +48,9 @@ public class User implements UserDetails { @OneToMany(cascade = CascadeType.ALL, mappedBy = "player") private Set eventsPlayed = new HashSet<>(); + @OneToMany(cascade = CascadeType.ALL, mappedBy = "user") + private Set tokens = new HashSet<>(); + public User(String email, String password, String name, Gender gender, LocalDate dateOfBirth) { this.email = email; this.password = password; @@ -68,9 +71,9 @@ public String getPassword() { } /** - * Gets the email associated with User. + * Returns the user's email as the username. * - * @return this.email instead of this.name * + * @return The email address. */ @Override public String getUsername() { diff --git a/backend/sportsmatch/src/main/java/com/sportsmatch/repositories/TokenRepository.java b/backend/sportsmatch/src/main/java/com/sportsmatch/repositories/TokenRepository.java new file mode 100644 index 00000000..e43317a2 --- /dev/null +++ b/backend/sportsmatch/src/main/java/com/sportsmatch/repositories/TokenRepository.java @@ -0,0 +1,11 @@ +package com.sportsmatch.repositories; + +import com.sportsmatch.models.Token; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface TokenRepository extends JpaRepository { + + boolean existsByToken(String token); +} diff --git a/backend/sportsmatch/src/main/java/com/sportsmatch/repositories/UserRepository.java b/backend/sportsmatch/src/main/java/com/sportsmatch/repositories/UserRepository.java index 5767d5f3..f6890293 100644 --- a/backend/sportsmatch/src/main/java/com/sportsmatch/repositories/UserRepository.java +++ b/backend/sportsmatch/src/main/java/com/sportsmatch/repositories/UserRepository.java @@ -11,4 +11,6 @@ public interface UserRepository extends JpaRepository { Optional findUserById(Long id); Optional findByEmail(String email); + + boolean existsByEmail(String email); }