diff --git a/pom.xml b/pom.xml index f35a823..0bfbb1f 100644 --- a/pom.xml +++ b/pom.xml @@ -51,6 +51,18 @@ spring-boot-starter-flyway + + + org.springframework.boot + spring-boot-starter-security + + + + + org.springframework.boot + spring-boot-starter-oauth2-client + + org.flywaydb @@ -88,6 +100,30 @@ og4dev-spring-response 1.4.0 + + + + io.jsonwebtoken + jjwt-api + 0.13.0 + compile + + + + + io.jsonwebtoken + jjwt-impl + 0.13.0 + runtime + + + + + io.jsonwebtoken + jjwt-jackson + 0.13.0 + runtime + \ No newline at end of file diff --git a/src/main/java/dev/pasinduog/eventsphere/config/AppConfig.java b/src/main/java/dev/pasinduog/eventsphere/config/AppConfig.java index c238438..f5ca726 100644 --- a/src/main/java/dev/pasinduog/eventsphere/config/AppConfig.java +++ b/src/main/java/dev/pasinduog/eventsphere/config/AppConfig.java @@ -1,9 +1,16 @@ package dev.pasinduog.eventsphere.config; import com.fasterxml.jackson.databind.ObjectMapper; +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.client.JdkClientHttpRequestFactory; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.web.client.RestClient; import java.net.http.HttpClient; @@ -34,4 +41,31 @@ public RestClient restClient() { public Logger logger() { return Logger.getLogger("dev.pasinduog.eventsphere.config"); } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public OpenAPI openApiConfig() { + final String securitySchemeName = "bearerAuth"; + + return new OpenAPI().info( + new Info() + .title("EventSphere API") + .description("This is the EventSphere API Documentation") + .version("1.0.0") + .termsOfService("https://github.com/pasinduog/eventsphere")) + .addSecurityItem(new SecurityRequirement().addList(securitySchemeName)) + .components(new Components() + .addSecuritySchemes(securitySchemeName, + new SecurityScheme() + .name(securitySchemeName) + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT") + .description("Enter your JWT token. You can obtain it from the /api/v1/auth/login endpoint.") + )); + } } diff --git a/src/main/java/dev/pasinduog/eventsphere/config/SecurityConfig.java b/src/main/java/dev/pasinduog/eventsphere/config/SecurityConfig.java new file mode 100644 index 0000000..79408d9 --- /dev/null +++ b/src/main/java/dev/pasinduog/eventsphere/config/SecurityConfig.java @@ -0,0 +1,97 @@ +package dev.pasinduog.eventsphere.config; + +import dev.pasinduog.eventsphere.filter.JwtAuthFilter; +import dev.pasinduog.eventsphere.service.CustomOAuth2UserService; +import dev.pasinduog.eventsphere.service.OAuth2CodeService; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +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.oauth2.core.user.DefaultOAuth2User; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +import org.springframework.web.util.UriComponentsBuilder; + +import java.util.List; + +@Configuration +@EnableWebSecurity +@EnableMethodSecurity +@RequiredArgsConstructor +public class SecurityConfig { + private final CustomOAuth2UserService customOAuth2UserService; + private final JwtAuthFilter jwtAuthFilter; + private final OAuth2CodeService oAuth2CodeService; + + @Value("${app.frontend.base-url}") + private String frontendBaseUrl; + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) { + http + .cors(cors -> cors.configurationSource(corsConfigurationSource())) // අනිවාර්යයි! + .csrf(AbstractHttpConfigurer::disable) + .authorizeHttpRequests(auth -> auth + .requestMatchers( + "/api/v1/auth/**", + "/api/v1/users/register", + "/swagger-ui/**", + "/v3/api-docs/**", + "/swagger-ui.html", + "/ws-event-chat/**", + "/oauth2/authorization/**", + "/login/oauth2/code/**" + ).permitAll() + .anyRequest().authenticated() + ) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)) + .addFilterBefore(jwtAuthFilter, org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter.class) + .oauth2Login(oauth2 -> oauth2 + .userInfoEndpoint(userInfo -> userInfo + .userService(customOAuth2UserService) + ) + .successHandler((request, response, authentication) -> { + DefaultOAuth2User oauthUser = (DefaultOAuth2User) authentication.getPrincipal(); + if (oauthUser != null && oauthUser.getAttribute("email") != null) { + String email = oauthUser.getAttribute("email"); + String code = oAuth2CodeService.generateCode(email); + response.sendRedirect(buildFrontendLoginRedirect("code", code)); + } else { + response.sendRedirect(buildFrontendLoginRedirect("error", "github_email_missing")); + } + }) + ) + .logout(logout -> logout + .logoutUrl("/api/v1/auth/logout") + .logoutSuccessHandler((request, response, authentication) -> response.setStatus(200)) + ); + return http.build(); + } + + private String buildFrontendLoginRedirect(String parameterName, String parameterValue) { + return UriComponentsBuilder.fromUriString(frontendBaseUrl) + .path("/login") + .queryParam(parameterName, parameterValue) + .build() + .toUriString(); + } + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + configuration.setAllowedOrigins(List.of(frontendBaseUrl)); + configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS")); + configuration.setAllowedHeaders(List.of("Authorization", "Cache-Control", "Content-Type")); + configuration.setAllowCredentials(true); + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + return source; + } +} diff --git a/src/main/java/dev/pasinduog/eventsphere/controller/AuthController.java b/src/main/java/dev/pasinduog/eventsphere/controller/AuthController.java new file mode 100644 index 0000000..3e1fc41 --- /dev/null +++ b/src/main/java/dev/pasinduog/eventsphere/controller/AuthController.java @@ -0,0 +1,53 @@ +package dev.pasinduog.eventsphere.controller; + +import dev.pasinduog.eventsphere.dto.LoginRequest; +import dev.pasinduog.eventsphere.dto.LoginResponse; +import dev.pasinduog.eventsphere.dto.OAuth2CallbackRequest; +import dev.pasinduog.eventsphere.exception.InvalidAuthCodeException; +import dev.pasinduog.eventsphere.exception.UserNotFoundException; +import dev.pasinduog.eventsphere.model.User; +import dev.pasinduog.eventsphere.repository.UserRepository; +import dev.pasinduog.eventsphere.service.JwtService; +import dev.pasinduog.eventsphere.service.OAuth2CodeService; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/v1/auth") +@RequiredArgsConstructor +public class AuthController { + + private final UserRepository userRepository; + private final JwtService jwtService; + private final PasswordEncoder passwordEncoder; + private final OAuth2CodeService oAuth2CodeService; + + @PostMapping("/login") + public LoginResponse login(@RequestBody LoginRequest request) { + User user = userRepository.findByEmail(request.email()) + .orElseThrow(() -> new UserNotFoundException("User not found")); + + if (!passwordEncoder.matches(request.password(), user.getPasswordHash())) { + throw new BadCredentialsException("Invalid credentials"); + } + String token = jwtService.generateToken(user); + return new LoginResponse(token); + } + + @PostMapping("/oauth2/callback") + public LoginResponse oauth2Callback(@RequestBody OAuth2CallbackRequest request) { + String email = oAuth2CodeService.validateCodeAndGetEmail(request.code()); + + if (email == null) { + throw new InvalidAuthCodeException("Invalid or expired authorization code"); + } + + User user = userRepository.findByEmail(email) + .orElseThrow(() -> new UserNotFoundException("User not found")); + + String token = jwtService.generateToken(user); + return new LoginResponse(token); + } +} \ No newline at end of file diff --git a/src/main/java/dev/pasinduog/eventsphere/controller/EventController.java b/src/main/java/dev/pasinduog/eventsphere/controller/EventController.java index 25346dd..386ab38 100644 --- a/src/main/java/dev/pasinduog/eventsphere/controller/EventController.java +++ b/src/main/java/dev/pasinduog/eventsphere/controller/EventController.java @@ -6,6 +6,7 @@ import dev.pasinduog.eventsphere.service.AiMatchmakingService; import dev.pasinduog.eventsphere.service.EventService; import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; import java.util.List; @@ -23,21 +24,25 @@ List getUpcomingEvents() { } @GetMapping("/{eventId}/matches") + @PreAuthorize("isAuthenticated()") List getMatchSuggestions(@PathVariable String eventId, @RequestParam String userId) { return aiMatchmakingService.getMatchSuggestions(eventId, userId); } @PostMapping + @PreAuthorize("hasAuthority('ORGANIZER') or hasAuthority('ADMIN')") boolean createEvent(@RequestBody Event event){ return eventService.createEvent(event); } @PostMapping("/{eventId}/register") + @PreAuthorize("isAuthenticated()") boolean registerEvent(@PathVariable String eventId, @RequestParam String userId){ return eventService.registerUserForEvent(eventId, userId); } @PostMapping("/{eventId}/matchmaking") + @PreAuthorize("isAuthenticated()") AiMatchResult generateNetworkingMatches(@PathVariable String eventId, @RequestParam String userId){ return aiMatchmakingService.generateMatchesForUser(eventId, userId); } diff --git a/src/main/java/dev/pasinduog/eventsphere/controller/UserController.java b/src/main/java/dev/pasinduog/eventsphere/controller/UserController.java index 4ba99b3..4516c34 100644 --- a/src/main/java/dev/pasinduog/eventsphere/controller/UserController.java +++ b/src/main/java/dev/pasinduog/eventsphere/controller/UserController.java @@ -1,26 +1,54 @@ package dev.pasinduog.eventsphere.controller; +import dev.pasinduog.eventsphere.dto.RegisterRequest; import dev.pasinduog.eventsphere.dto.UserResponse; import dev.pasinduog.eventsphere.model.User; import dev.pasinduog.eventsphere.service.UserService; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; +import java.security.Principal; +import java.util.Map; + @RestController @RequestMapping("/api/v1/users") @RequiredArgsConstructor public class UserController { private final UserService userService; + @GetMapping("/by-email") + @PreAuthorize("hasAuthority('ADMIN')") + UserResponse getUserByEmail(@RequestParam String email) { + return userService.getUserByEmail(email); + } + + @GetMapping + @PreAuthorize("hasAuthority('ADMIN')") + UserResponse getUserById(@RequestParam String userId) { + return userService.getUserById(userId); + } + + @GetMapping("/me") + @PreAuthorize("isAuthenticated()") + UserResponse getCurrentUser(Principal principal) { + String email = principal.getName(); + return userService.getUserByEmail(email); + } + @PostMapping("/register") @ResponseStatus(HttpStatus.CREATED) - boolean registerUser(@RequestBody User user) { - return userService.registerUser(user); + boolean registerUser(@RequestBody RegisterRequest request) { + return userService.registerUser(request); } - @GetMapping("/by-email") - UserResponse getUserByEmail(@RequestParam String email) { - return userService.getUserByEmail(email); + @PutMapping("/me/profile") + @PreAuthorize("isAuthenticated()") + boolean updateProfile(Principal principal, @RequestBody Map updates){ + User user = userService.getUserEntityByEmail(principal.getName()); + if (updates.containsKey("skillsAndInterests")) user.setSkillsAndInterests(updates.get("skillsAndInterests")); + if (updates.containsKey("fullName")) user.setFullName(updates.get("fullName")); + return userService.updateUser(user); } } diff --git a/src/main/java/dev/pasinduog/eventsphere/dto/LoginRequest.java b/src/main/java/dev/pasinduog/eventsphere/dto/LoginRequest.java new file mode 100644 index 0000000..cb7308f --- /dev/null +++ b/src/main/java/dev/pasinduog/eventsphere/dto/LoginRequest.java @@ -0,0 +1,6 @@ +package dev.pasinduog.eventsphere.dto; + +public record LoginRequest( + String email, + String password +) {} diff --git a/src/main/java/dev/pasinduog/eventsphere/dto/LoginResponse.java b/src/main/java/dev/pasinduog/eventsphere/dto/LoginResponse.java new file mode 100644 index 0000000..2e1622e --- /dev/null +++ b/src/main/java/dev/pasinduog/eventsphere/dto/LoginResponse.java @@ -0,0 +1,4 @@ +package dev.pasinduog.eventsphere.dto; + +public record LoginResponse(String token) { +} diff --git a/src/main/java/dev/pasinduog/eventsphere/dto/OAuth2CallbackRequest.java b/src/main/java/dev/pasinduog/eventsphere/dto/OAuth2CallbackRequest.java new file mode 100644 index 0000000..6f16217 --- /dev/null +++ b/src/main/java/dev/pasinduog/eventsphere/dto/OAuth2CallbackRequest.java @@ -0,0 +1,4 @@ +package dev.pasinduog.eventsphere.dto; + +public record OAuth2CallbackRequest(String code) { +} diff --git a/src/main/java/dev/pasinduog/eventsphere/dto/RegisterRequest.java b/src/main/java/dev/pasinduog/eventsphere/dto/RegisterRequest.java new file mode 100644 index 0000000..8abc0db --- /dev/null +++ b/src/main/java/dev/pasinduog/eventsphere/dto/RegisterRequest.java @@ -0,0 +1,9 @@ +package dev.pasinduog.eventsphere.dto; + +public record RegisterRequest( + String fullName, + String email, + String password, + String skillsAndInterests +) { +} diff --git a/src/main/java/dev/pasinduog/eventsphere/exception/InvalidAuthCodeException.java b/src/main/java/dev/pasinduog/eventsphere/exception/InvalidAuthCodeException.java new file mode 100644 index 0000000..9988d2e --- /dev/null +++ b/src/main/java/dev/pasinduog/eventsphere/exception/InvalidAuthCodeException.java @@ -0,0 +1,10 @@ +package dev.pasinduog.eventsphere.exception; + +import io.github.og4dev.exception.ApiException; +import org.springframework.http.HttpStatus; + +public class InvalidAuthCodeException extends ApiException { + public InvalidAuthCodeException(String message) { + super(message, HttpStatus.UNAUTHORIZED); + } +} diff --git a/src/main/java/dev/pasinduog/eventsphere/filter/JwtAuthFilter.java b/src/main/java/dev/pasinduog/eventsphere/filter/JwtAuthFilter.java new file mode 100644 index 0000000..b35912e --- /dev/null +++ b/src/main/java/dev/pasinduog/eventsphere/filter/JwtAuthFilter.java @@ -0,0 +1,60 @@ +package dev.pasinduog.eventsphere.filter; + +import dev.pasinduog.eventsphere.model.User; +import dev.pasinduog.eventsphere.repository.UserRepository; +import dev.pasinduog.eventsphere.service.JwtService; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.jspecify.annotations.NullMarked; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +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 UserRepository userRepository; + + @Override + @NullMarked + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + final String authHeader = request.getHeader("Authorization"); + final String jwt; + final String userEmail; + + if (authHeader == null || !authHeader.startsWith("Bearer ")) { + filterChain.doFilter(request, response); + return; + } + + jwt = authHeader.substring(7); + try { + userEmail = jwtService.extractUsername(jwt); + } catch (RuntimeException e) { + filterChain.doFilter(request, response); + return; + } + if (userEmail != null && SecurityContextHolder.getContext().getAuthentication() == null) { + User user = userRepository.findByEmail(userEmail).orElse(null); + if (user != null && jwtService.isTokenValid(jwt, user)) { + UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken( + user, + null, + user.getAuthorities() + ); + + authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + SecurityContextHolder.getContext().setAuthentication(authToken); + } + } + filterChain.doFilter(request, response); + } +} diff --git a/src/main/java/dev/pasinduog/eventsphere/model/User.java b/src/main/java/dev/pasinduog/eventsphere/model/User.java index 8863a85..6974c2e 100644 --- a/src/main/java/dev/pasinduog/eventsphere/model/User.java +++ b/src/main/java/dev/pasinduog/eventsphere/model/User.java @@ -4,14 +4,22 @@ import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; import java.time.LocalDateTime; +import java.util.Collection; +import java.util.List; @AllArgsConstructor @NoArgsConstructor @Getter @Setter -public class User { +@NullMarked +public class User implements UserDetails { private String id; private String fullName; private String email; @@ -19,4 +27,20 @@ public class User { private String role; private String skillsAndInterests; private LocalDateTime createdAt; + + + @Override + public Collection getAuthorities() { + return List.of(new SimpleGrantedAuthority(this.role)); + } + + @Override + public @Nullable String getPassword() { + return this.passwordHash; + } + + @Override + public String getUsername() { + return this.email; + } } \ No newline at end of file diff --git a/src/main/java/dev/pasinduog/eventsphere/repository/UserRepository.java b/src/main/java/dev/pasinduog/eventsphere/repository/UserRepository.java index b694e3c..fd7e0a7 100644 --- a/src/main/java/dev/pasinduog/eventsphere/repository/UserRepository.java +++ b/src/main/java/dev/pasinduog/eventsphere/repository/UserRepository.java @@ -7,6 +7,7 @@ public interface UserRepository { boolean save(User user); + boolean update(User user); Optional findById(String id); List findByIds(List ids); Optional findByEmail(String email); diff --git a/src/main/java/dev/pasinduog/eventsphere/repository/impl/UserRepositoryImpl.java b/src/main/java/dev/pasinduog/eventsphere/repository/impl/UserRepositoryImpl.java index 0af9c2f..69357f5 100644 --- a/src/main/java/dev/pasinduog/eventsphere/repository/impl/UserRepositoryImpl.java +++ b/src/main/java/dev/pasinduog/eventsphere/repository/impl/UserRepositoryImpl.java @@ -18,6 +18,20 @@ public class UserRepositoryImpl implements UserRepository { private final JdbcTemplate jdbcTemplate; private RowMapper rowMapper() { + return ((rs, rowNum) -> { + User user = new User(); + user.setId(rs.getString("id")); + user.setFullName(rs.getString("full_name")); + user.setEmail(rs.getString("email")); + user.setRole(rs.getString("role")); + user.setPasswordHash(rs.getString("password_hash")); + user.setSkillsAndInterests(rs.getString("skills_and_interests")); + user.setCreatedAt(rs.getTimestamp("created_at").toLocalDateTime()); + return user; + }); + } + + private RowMapper customRowMapper() { return ((rs, rowNum) -> { User user = new User(); user.setId(rs.getString("id")); @@ -46,9 +60,15 @@ public boolean save(User user) { } } + @Override + public boolean update(User user) { + String sql = "UPDATE users SET full_name = ?, skills_and_interests = ? WHERE id = ?"; + return jdbcTemplate.update(sql, user.getFullName(), user.getSkillsAndInterests(), user.getId()) > 0; + } + @Override public Optional findById(String id) { - String sql = "SELECT id, full_name, email, `role`, skills_and_interests, created_at FROM users WHERE id = ?"; + String sql = "SELECT id, full_name, email, `role`, password_hash, skills_and_interests, created_at FROM users WHERE id = ?"; return jdbcTemplate.query(sql, rowMapper(), id).stream().findFirst(); } @@ -58,19 +78,19 @@ public List findByIds(List ids) { return List.of(); } String inSql = String.join(",", java.util.Collections.nCopies(ids.size(), "?")); - String sql = "SELECT id, full_name, email, `role`, skills_and_interests, created_at FROM users WHERE id IN (" + inSql + ")"; - return jdbcTemplate.query(sql, rowMapper()); + String sql = "SELECT id, full_name, email, `role`, password_hash, skills_and_interests, created_at FROM users WHERE id IN (" + inSql + ")"; + return jdbcTemplate.query(sql, rowMapper(), ids.toArray()); } @Override public Optional findByEmail(String email) { - String sql = "SELECT id, full_name, email, `role`, skills_and_interests, created_at FROM users WHERE email = ?"; + String sql = "SELECT id, full_name, email, `role`, password_hash, skills_and_interests, created_at FROM users WHERE email = ?"; return jdbcTemplate.query(sql, rowMapper(), email).stream().findFirst(); } @Override public List findAll() { String sql = "SELECT id, full_name, email, `role`, skills_and_interests, created_at FROM users"; - return jdbcTemplate.query(sql, rowMapper()); + return jdbcTemplate.query(sql, customRowMapper()); } } diff --git a/src/main/java/dev/pasinduog/eventsphere/service/CustomOAuth2UserService.java b/src/main/java/dev/pasinduog/eventsphere/service/CustomOAuth2UserService.java new file mode 100644 index 0000000..cec9745 --- /dev/null +++ b/src/main/java/dev/pasinduog/eventsphere/service/CustomOAuth2UserService.java @@ -0,0 +1,120 @@ +package dev.pasinduog.eventsphere.service; + +import dev.pasinduog.eventsphere.model.User; +import dev.pasinduog.eventsphere.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestTemplate; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.core.ParameterizedTypeReference; + +import java.time.LocalDateTime; +import java.util.*; +import java.util.logging.Logger; +import org.springframework.security.oauth2.core.user.DefaultOAuth2User; + +@Service +@RequiredArgsConstructor +public class CustomOAuth2UserService extends DefaultOAuth2UserService { + private final UserRepository userRepository; + private final Logger logger; + private final PasswordEncoder passwordEncoder; + + @Value("#{'${security.admin-emails:}'.empty ? {} : '${security.admin-emails:}'.split(',')}") + private List adminEmails; + + @Override + public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { + OAuth2User oAuth2User = super.loadUser(userRequest); + String email = oAuth2User.getAttribute("email"); + String name = oAuth2User.getAttribute("name"); + if (name == null) name = oAuth2User.getAttribute("login"); + + if (email == null) { + email = fetchEmailFromGitHub(userRequest.getAccessToken().getTokenValue()); + } + + if (email == null) { + throw new OAuth2AuthenticationException(new OAuth2Error("email_not_found", "Email not found from GitHub provider", null)); + } + + oAuth2User = enhanceOAuth2UserWithEmail(oAuth2User, email); + registerNewUserIfNeeded(email, name); + + return oAuth2User; + } + + private String fetchEmailFromGitHub(String token) { + RestTemplate restTemplate = new RestTemplate(); + HttpHeaders headers = new HttpHeaders(); + headers.setBearerAuth(token); + HttpEntity entity = new HttpEntity<>("", headers); + + ResponseEntity>> response; + try { + response = restTemplate.exchange( + "https://api.github.com/user/emails", + HttpMethod.GET, + entity, + new ParameterizedTypeReference<>() {} + ); + } catch (RestClientException ex) { + logger.warning("Failed to fetch email from GitHub API: " + ex.getMessage()); + throw new OAuth2AuthenticationException( + new OAuth2Error("github_email_fetch_failed", + "Unable to retrieve email address from GitHub. Please ensure your GitHub account has a verified primary email.", null)); + } + + List> emails = response.getBody(); + if (emails != null) { + for (Map emailObj : emails) { + if (Boolean.TRUE.equals(emailObj.get("primary")) && Boolean.TRUE.equals(emailObj.get("verified"))) { + return (String) emailObj.get("email"); + } + } + } + return null; + } + + private OAuth2User enhanceOAuth2UserWithEmail(OAuth2User oAuth2User, String email) { + Map attributes = new HashMap<>(oAuth2User.getAttributes()); + if (!attributes.containsKey("email") || attributes.get("email") == null) { + attributes.put("email", email); + } + return new DefaultOAuth2User(oAuth2User.getAuthorities(), attributes, "email"); + } + + private void registerNewUserIfNeeded(String email, String name) { + Optional userOptional = userRepository.findByEmail(email); + if (userOptional.isEmpty()) { + User user = new User(); + user.setId(UUID.randomUUID().toString()); + user.setFullName(name); + user.setEmail(email); + user.setPasswordHash(Objects.requireNonNull(passwordEncoder.encode(UUID.randomUUID().toString()))); + if (adminEmails.contains(email)) { + user.setRole("ADMIN"); + } else { + user.setRole("ATTENDEE"); + } + user.setSkillsAndInterests("GitHub User"); + user.setCreatedAt(LocalDateTime.now()); + + userRepository.save(user); + logger.info("New user registered via GitHub with role: " + user.getRole()); + } else { + logger.warning("User already exists"); + } + } +} diff --git a/src/main/java/dev/pasinduog/eventsphere/service/JwtService.java b/src/main/java/dev/pasinduog/eventsphere/service/JwtService.java new file mode 100644 index 0000000..bdcc1a2 --- /dev/null +++ b/src/main/java/dev/pasinduog/eventsphere/service/JwtService.java @@ -0,0 +1,21 @@ +package dev.pasinduog.eventsphere.service; + +import io.jsonwebtoken.Claims; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import javax.crypto.SecretKey; +import java.util.Collection; +import java.util.Date; + +public interface JwtService { + String generateToken(UserDetails userDetails); + String getFirstAuthority(Collection authorities); + boolean isTokenValid(String token, UserDetails userDetails); + boolean isTokenExpired(String token); + String extractUsername(String token); + String extractRole(String token); + Date extractExpiration(String token); + Claims extractAllClaims(String token); + SecretKey getSigningKey(); +} \ No newline at end of file diff --git a/src/main/java/dev/pasinduog/eventsphere/service/OAuth2CodeService.java b/src/main/java/dev/pasinduog/eventsphere/service/OAuth2CodeService.java new file mode 100644 index 0000000..22eea90 --- /dev/null +++ b/src/main/java/dev/pasinduog/eventsphere/service/OAuth2CodeService.java @@ -0,0 +1,6 @@ +package dev.pasinduog.eventsphere.service; + +public interface OAuth2CodeService { + String generateCode(String email); + String validateCodeAndGetEmail(String code); +} diff --git a/src/main/java/dev/pasinduog/eventsphere/service/UserService.java b/src/main/java/dev/pasinduog/eventsphere/service/UserService.java index 7541887..4d4aac1 100644 --- a/src/main/java/dev/pasinduog/eventsphere/service/UserService.java +++ b/src/main/java/dev/pasinduog/eventsphere/service/UserService.java @@ -1,9 +1,13 @@ package dev.pasinduog.eventsphere.service; +import dev.pasinduog.eventsphere.dto.RegisterRequest; import dev.pasinduog.eventsphere.dto.UserResponse; import dev.pasinduog.eventsphere.model.User; public interface UserService { - boolean registerUser(User user); + boolean registerUser(RegisterRequest request); UserResponse getUserByEmail(String email); + UserResponse getUserById(String userId); + boolean updateUser(User user); + User getUserEntityByEmail(String email); } diff --git a/src/main/java/dev/pasinduog/eventsphere/service/impl/AiMatchmakingServiceImpl.java b/src/main/java/dev/pasinduog/eventsphere/service/impl/AiMatchmakingServiceImpl.java index 543061d..6ab9e5a 100644 --- a/src/main/java/dev/pasinduog/eventsphere/service/impl/AiMatchmakingServiceImpl.java +++ b/src/main/java/dev/pasinduog/eventsphere/service/impl/AiMatchmakingServiceImpl.java @@ -31,10 +31,10 @@ public class AiMatchmakingServiceImpl implements AiMatchmakingService { private final AiMatchSuggestionRepository aiMatchSuggestionRepository; private final EventRegistrationRepository eventRegistrationRepository; - @Value("${gemini.api.key}") + @Value("${Gemini.api.key}") private String apiKey; - @Value("${gemini.api.url}") + @Value("${Gemini.api.url}") private String apiUrl; @Override diff --git a/src/main/java/dev/pasinduog/eventsphere/service/impl/JwtServiceImpl.java b/src/main/java/dev/pasinduog/eventsphere/service/impl/JwtServiceImpl.java new file mode 100644 index 0000000..e795c90 --- /dev/null +++ b/src/main/java/dev/pasinduog/eventsphere/service/impl/JwtServiceImpl.java @@ -0,0 +1,88 @@ +package dev.pasinduog.eventsphere.service.impl; + +import dev.pasinduog.eventsphere.service.JwtService; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Service; + +import javax.crypto.SecretKey; +import java.util.Collection; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; + +@Service +@RequiredArgsConstructor +@SuppressWarnings("unused") +public class JwtServiceImpl implements JwtService { + + @Value("${security.jwt.key}") + private String secretKey; + + @Value("${security.jwt.expiration}") + private long expiration; + + @Override + public String generateToken(UserDetails userDetails) { + Map extraClaims = new HashMap<>(); + extraClaims.put("role", getFirstAuthority(userDetails.getAuthorities())); + return Jwts.builder() + .claims(extraClaims) + .subject(userDetails.getUsername()) + .issuedAt(new Date(System.currentTimeMillis())) + .expiration(new Date(System.currentTimeMillis() + expiration)) + .signWith(getSigningKey()) + .compact(); + } + + @Override + public String getFirstAuthority(Collection authorities) { + return authorities.stream().findFirst().map(GrantedAuthority::getAuthority) + .orElse("ATTENDEE"); + } + + @Override + public boolean isTokenValid(String token, UserDetails userDetails) { + return extractUsername(token).equals(userDetails.getUsername()) && !isTokenExpired(token); + } + + @Override + public boolean isTokenExpired(String token) { + return extractExpiration(token).before(new Date()); + } + + @Override + public String extractUsername(String token) { + return extractAllClaims(token).getSubject(); + } + + @Override + public String extractRole(String token) { + return (String) extractAllClaims(token).get("role"); + } + + @Override + public Date extractExpiration(String token) { + return extractAllClaims(token).getExpiration(); + } + + @Override + public Claims extractAllClaims(String token) { + return Jwts.parser() + .verifyWith(getSigningKey()) + .build() + .parseSignedClaims(token) + .getPayload(); + } + + @Override + public SecretKey getSigningKey() { + return Keys.hmacShaKeyFor(Decoders.BASE64.decode(secretKey)); + } +} \ No newline at end of file diff --git a/src/main/java/dev/pasinduog/eventsphere/service/impl/OAuth2CodeServiceImpl.java b/src/main/java/dev/pasinduog/eventsphere/service/impl/OAuth2CodeServiceImpl.java new file mode 100644 index 0000000..1662a0d --- /dev/null +++ b/src/main/java/dev/pasinduog/eventsphere/service/impl/OAuth2CodeServiceImpl.java @@ -0,0 +1,34 @@ +package dev.pasinduog.eventsphere.service.impl; + +import dev.pasinduog.eventsphere.service.OAuth2CodeService; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class OAuth2CodeServiceImpl implements OAuth2CodeService { + + private final StringRedisTemplate redisTemplate; + private static final Duration EXPIRATION_TIME = Duration.ofMinutes(5); + + @Override + public String generateCode(String email) { + String code = UUID.randomUUID().toString(); + redisTemplate.opsForValue().set("oauth2_code:" + code, email, EXPIRATION_TIME); + return code; + } + + @Override + public String validateCodeAndGetEmail(String code) { + String key = "oauth2_code:" + code; + String email = redisTemplate.opsForValue().get(key); + if (email == null) { + redisTemplate.delete(key); + } + return email; + } +} diff --git a/src/main/java/dev/pasinduog/eventsphere/service/impl/UserServiceImpl.java b/src/main/java/dev/pasinduog/eventsphere/service/impl/UserServiceImpl.java index 88045a3..4b60aa9 100644 --- a/src/main/java/dev/pasinduog/eventsphere/service/impl/UserServiceImpl.java +++ b/src/main/java/dev/pasinduog/eventsphere/service/impl/UserServiceImpl.java @@ -1,23 +1,37 @@ package dev.pasinduog.eventsphere.service.impl; +import dev.pasinduog.eventsphere.dto.RegisterRequest; import dev.pasinduog.eventsphere.dto.UserResponse; +import dev.pasinduog.eventsphere.exception.UserAlreadyExistsException; import dev.pasinduog.eventsphere.exception.UserNotFoundException; import dev.pasinduog.eventsphere.model.User; import dev.pasinduog.eventsphere.repository.UserRepository; import dev.pasinduog.eventsphere.service.UserService; import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; +import java.util.Objects; import java.util.UUID; @Service @RequiredArgsConstructor public class UserServiceImpl implements UserService { private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; @Override - public boolean registerUser(User user) { + public boolean registerUser(RegisterRequest request) { + if (userRepository.findByEmail(request.email()).isPresent()) { + throw new UserAlreadyExistsException("Email already exists"); + } + User user = new User(); user.setId(UUID.randomUUID().toString()); + user.setFullName(request.fullName()); + user.setEmail(request.email()); + user.setPasswordHash(Objects.requireNonNull(passwordEncoder.encode(request.password()))); + user.setRole("ATTENDEE"); + user.setSkillsAndInterests(request.skillsAndInterests()); return userRepository.save(user); } @@ -34,4 +48,28 @@ public UserResponse getUserByEmail(String email) { user.getSkillsAndInterests() ); } + + @Override + public UserResponse getUserById(String userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new UserNotFoundException("User not found with id: " + userId)); + + return new UserResponse( + user.getId(), + user.getFullName(), + user.getEmail(), + user.getRole(), + user.getSkillsAndInterests() + ); + } + + @Override + public boolean updateUser(User user) { + return userRepository.update(user); + } + + @Override + public User getUserEntityByEmail(String email) { + return userRepository.findByEmail(email).orElseThrow(() -> new UserNotFoundException("User not found")); + } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index b525513..b7134b4 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,7 +1,7 @@ spring: datasource: url: jdbc:mysql://localhost:3306/virtual_events_db?createDatabaseIfNotExist=true - username: root + username: ${DB_USERNAME} password: ${DB_PASSWORD} driver-class-name: com.mysql.cj.jdbc.Driver @@ -9,7 +9,31 @@ spring: enabled: true baseline-on-migrate: true -gemini: + security: + oauth2: + client: + registration: + github: + client-id: ${GITHUB_CLIENT_ID} + client-secret: ${GITHUB_CLIENT_SECRET} + scope: user:email, read:user + + data: + redis: + host: localhost + port: 6379 + +Gemini: api: url: https://generativelanguage.googleapis.com/v1beta/models/gemini-flash-latest:generateContent - key: ${GEMINI_API_KEY} \ No newline at end of file + key: ${GEMINI_API_KEY} + +security: + jwt: + key: ${JWT_KEY} + expiration: 1640995200 + admin-emails: pasinduogdev@gmail.com + +app: + frontend: + base-url: ${BASE_URL} \ No newline at end of file diff --git a/src/main/resources/db/migration/V1__init_schema.sql b/src/main/resources/db/migration/V1__init_schema.sql index 0c40f40..5a2994b 100644 --- a/src/main/resources/db/migration/V1__init_schema.sql +++ b/src/main/resources/db/migration/V1__init_schema.sql @@ -1,15 +1,26 @@ -- V1__init_schema.sql -- Database: virtual_events_db (MySQL) +-- 0.1 ROLES +CREATE TABLE roles ( + name VARCHAR(50) PRIMARY KEY +); + +-- 0.2 EVENT STATUSES +CREATE TABLE event_statuses ( + name VARCHAR(50) PRIMARY KEY +); + -- 1. USERS TABLE CREATE TABLE users ( id VARCHAR(36) PRIMARY KEY, full_name VARCHAR(150) NOT NULL, email VARCHAR(150) UNIQUE NOT NULL, password_hash VARCHAR(255) NOT NULL, - role ENUM('ADMIN', 'ATTENDEE', 'SPEAKER') DEFAULT 'ATTENDEE', + role VARCHAR(50) DEFAULT 'ATTENDEE', skills_and_interests TEXT, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (role) REFERENCES roles(name) ); -- 2. EVENTS TABLE @@ -21,9 +32,10 @@ CREATE TABLE events ( start_time DATETIME NOT NULL, end_time DATETIME NOT NULL, max_attendees INT NOT NULL, - status ENUM('UPCOMING', 'LIVE', 'COMPLETED') DEFAULT 'UPCOMING', + status VARCHAR(50) DEFAULT 'UPCOMING', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (organizer_id) REFERENCES users(id) ON DELETE CASCADE + FOREIGN KEY (organizer_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (status) REFERENCES event_statuses(name) ); -- 3. EVENT REGISTRATIONS (TICKETING) diff --git a/src/main/resources/db/migration/V3__insert_sample_data.sql b/src/main/resources/db/migration/V3__insert_sample_data.sql index 12b2aad..f1e7e15 100644 --- a/src/main/resources/db/migration/V3__insert_sample_data.sql +++ b/src/main/resources/db/migration/V3__insert_sample_data.sql @@ -1,18 +1,24 @@ -- V3__insert_sample_data.sql -- Insert Sample Data for Testing +-- 0.1. ROLE (3 Records) +INSERT INTO roles (name) VALUES ('ADMIN'), ('ATTENDEE'), ('SPEAKER'); + -- 1. USERS (10 Records - Admins, Speakers, Attendees) INSERT INTO users (id, full_name, email, password_hash, role, skills_and_interests) VALUES - ('886a8539-5426-4c95-b50c-c54d72e55d28', 'Pasindu Owa Gamage', 'admin1@eventsphere.com', 'dummy_hash_123', 'ADMIN', 'System Architecture, Spring Boot, Angular'), - ('eb334f13-7461-4d28-9e0e-7b933a5e5e4e', 'Kasun Perera', 'admin2@eventsphere.com', 'dummy_hash_123', 'ADMIN', 'Event Management, Marketing'), - ('1ea3c9fd-ad68-4105-8220-3a15694bc083', 'Dr. Ruwan Kumara', 'speaker1@eventsphere.com', 'dummy_hash_123', 'SPEAKER', 'AI, Machine Learning, Python'), - ('94d5ca84-a17c-45ed-8506-cf4220f5fa80', 'Sarah Jenkins', 'speaker2@eventsphere.com', 'dummy_hash_123', 'SPEAKER', 'Cloud Computing, AWS, DevOps'), - ('7743f6b1-d520-4a8f-b7a5-18e1d384e0f4', 'Nimali Silva', 'nimali@gmail.com', 'dummy_hash_123', 'ATTENDEE', 'Java, Spring Boot, Looking for Internships'), - ('280ac445-578a-46c6-b879-7816c7c691bc', 'Chamara Fernando', 'chamara@gmail.com', 'dummy_hash_123', 'ATTENDEE', 'Angular, Frontend Development, UI/UX'), - ('67f35b78-02fe-4879-8fe5-04d3633d5a56', 'Lahiru Senanayake', 'lahiru@gmail.com', 'dummy_hash_123', 'ATTENDEE', 'Microservices, Docker, Kubernetes'), - ('f8bcd1ca-8ee9-4b29-a689-ee691614dd8d', 'Gayantha De Silva', 'gayantha@gmail.com', 'dummy_hash_123', 'ATTENDEE', 'Looking for open-source contributors, Java'), - ('a3affe9d-1b0e-444e-a883-a24221f9aeb2', 'Sanduni Perera', 'sanduni@gmail.com', 'dummy_hash_123', 'ATTENDEE', 'Data Science, Gemini AI APIs'), - ('5ff6e1d8-fa5c-4ff3-8e69-8d41ae1f728d', 'Tharindu Bandara', 'tharindu@gmail.com', 'dummy_hash_123', 'ATTENDEE', 'Spring Boot, Backend Development'); + ('886a8539-5426-4c95-b50c-c54d72e55d28', 'Pasindu Owa Gamage', 'admin1@gmail.com', '$2a$10$.g3Buxksd/EBNCE8UGoPA.1jCjrJJBspKoh3dfg7e5Tq2OIRF2emi', 'ADMIN', 'System Architecture, Spring Boot, Angular'), + ('eb334f13-7461-4d28-9e0e-7b933a5e5e4e', 'Kasun Perera', 'admin2@gmail.com', '$2a$10$.g3Buxksd/EBNCE8UGoPA.1jCjrJJBspKoh3dfg7e5Tq2OIRF2emi', 'ADMIN', 'Event Management, Marketing'), + ('1ea3c9fd-ad68-4105-8220-3a15694bc083', 'Dr. Ruwan Kumara', 'speaker1@gmail.com', '$2a$10$.g3Buxksd/EBNCE8UGoPA.1jCjrJJBspKoh3dfg7e5Tq2OIRF2emi', 'SPEAKER', 'AI, Machine Learning, Python'), + ('94d5ca84-a17c-45ed-8506-cf4220f5fa80', 'Sarah Jenkins', 'speaker2@gmail.com', '$2a$10$.g3Buxksd/EBNCE8UGoPA.1jCjrJJBspKoh3dfg7e5Tq2OIRF2emi', 'SPEAKER', 'Cloud Computing, AWS, DevOps'), + ('7743f6b1-d520-4a8f-b7a5-18e1d384e0f4', 'Nimali Silva', 'nimali@gmail.com', '$2a$10$.g3Buxksd/EBNCE8UGoPA.1jCjrJJBspKoh3dfg7e5Tq2OIRF2emi', 'ATTENDEE', 'Java, Spring Boot, Looking for Internships'), + ('280ac445-578a-46c6-b879-7816c7c691bc', 'Chamara Fernando', 'chamara@gmail.com', '$2a$10$.g3Buxksd/EBNCE8UGoPA.1jCjrJJBspKoh3dfg7e5Tq2OIRF2emi', 'ATTENDEE', 'Angular, Frontend Development, UI/UX'), + ('67f35b78-02fe-4879-8fe5-04d3633d5a56', 'Lahiru Senanayake', 'lahiru@gmail.com', '$2a$10$.g3Buxksd/EBNCE8UGoPA.1jCjrJJBspKoh3dfg7e5Tq2OIRF2emi', 'ATTENDEE', 'Microservices, Docker, Kubernetes'), + ('f8bcd1ca-8ee9-4b29-a689-ee691614dd8d', 'Gayantha De Silva', 'gayantha@gmail.com', '$2a$10$.g3Buxksd/EBNCE8UGoPA.1jCjrJJBspKoh3dfg7e5Tq2OIRF2emi', 'ATTENDEE', 'Looking for open-source contributors, Java'), + ('a3affe9d-1b0e-444e-a883-a24221f9aeb2', 'Sanduni Perera', 'sanduni@gmail.com', '$2a$10$.g3Buxksd/EBNCE8UGoPA.1jCjrJJBspKoh3dfg7e5Tq2OIRF2emi', 'ATTENDEE', 'Data Science, Gemini AI APIs'), + ('5ff6e1d8-fa5c-4ff3-8e69-8d41ae1f728d', 'Tharindu Bandara', 'tharindu@gmail.com', '$2a$10$.g3Buxksd/EBNCE8UGoPA.1jCjrJJBspKoh3dfg7e5Tq2OIRF2emi', 'ATTENDEE', 'Spring Boot, Backend Development'); + +-- 0.2. EVENT STATUS (4 Records) +INSERT INTO event_statuses (name) VALUES ('UPCOMING'), ('LIVE'), ('COMPLETED'), ('UNAVAILABLE'); -- 2. EVENTS (10 Records) INSERT INTO events (id, organizer_id, title, description, start_time, end_time, max_attendees, status) VALUES