From cc1979a06896a5808ea277b6141d81f617f572c2 Mon Sep 17 00:00:00 2001 From: Constantin Simonis Date: Wed, 21 May 2025 10:33:30 +0200 Subject: [PATCH] feat: add GitHub OAuth2 authentication support --- ...uth2AuthenticationProcessingException.java | 9 + .../szut/casino/security/AuthController.java | 6 +- .../casino/security/GitHubController.java | 61 ++++++ .../szut/casino/security/GitHubService.java | 196 ++++++++++++++++++ .../szut/casino/security/SecurityConfig.java | 10 +- .../security/dto/GithubCallbackDto.java | 8 + .../de/szut/casino/security/jwt/JwtUtils.java | 30 ++- .../oauth2/CustomOAuth2UserService.java | 108 ++++++++++ .../security/oauth2/GitHubOAuth2UserInfo.java | 30 +++ .../OAuth2AuthenticationSuccessHandler.java | 61 ++++++ .../casino/security/oauth2/OAuth2Config.java | 15 ++ .../security/oauth2/OAuth2UserInfo.java | 23 ++ .../oauth2/OAuth2UserInfoFactory.java | 17 ++ .../casino/security/oauth2/UserPrincipal.java | 110 ++++++++++ .../casino/security/service/AuthService.java | 1 - .../de/szut/casino/user/AuthProvider.java | 6 + .../java/de/szut/casino/user/UserEntity.java | 17 ++ .../de/szut/casino/user/UserRepository.java | 2 + .../src/main/resources/application.properties | 13 ++ frontend/src/app/app.routes.ts | 7 + .../feature/auth/login/login.component.html | 20 +- .../app/feature/auth/login/login.component.ts | 6 + .../auth/oauth2/oauth2-callback.component.ts | 62 ++++++ frontend/src/app/service/auth.service.ts | 35 +++- 24 files changed, 845 insertions(+), 8 deletions(-) create mode 100644 backend/src/main/java/de/szut/casino/exceptionHandling/exceptions/OAuth2AuthenticationProcessingException.java create mode 100644 backend/src/main/java/de/szut/casino/security/GitHubController.java create mode 100644 backend/src/main/java/de/szut/casino/security/GitHubService.java create mode 100644 backend/src/main/java/de/szut/casino/security/dto/GithubCallbackDto.java create mode 100644 backend/src/main/java/de/szut/casino/security/oauth2/CustomOAuth2UserService.java create mode 100644 backend/src/main/java/de/szut/casino/security/oauth2/GitHubOAuth2UserInfo.java create mode 100644 backend/src/main/java/de/szut/casino/security/oauth2/OAuth2AuthenticationSuccessHandler.java create mode 100644 backend/src/main/java/de/szut/casino/security/oauth2/OAuth2Config.java create mode 100644 backend/src/main/java/de/szut/casino/security/oauth2/OAuth2UserInfo.java create mode 100644 backend/src/main/java/de/szut/casino/security/oauth2/OAuth2UserInfoFactory.java create mode 100644 backend/src/main/java/de/szut/casino/security/oauth2/UserPrincipal.java create mode 100644 backend/src/main/java/de/szut/casino/user/AuthProvider.java create mode 100644 frontend/src/app/feature/auth/oauth2/oauth2-callback.component.ts diff --git a/backend/src/main/java/de/szut/casino/exceptionHandling/exceptions/OAuth2AuthenticationProcessingException.java b/backend/src/main/java/de/szut/casino/exceptionHandling/exceptions/OAuth2AuthenticationProcessingException.java new file mode 100644 index 0000000..3a1447e --- /dev/null +++ b/backend/src/main/java/de/szut/casino/exceptionHandling/exceptions/OAuth2AuthenticationProcessingException.java @@ -0,0 +1,9 @@ +package de.szut.casino.exceptionHandling.exceptions; + +import org.springframework.security.core.AuthenticationException; + +public class OAuth2AuthenticationProcessingException extends AuthenticationException { + public OAuth2AuthenticationProcessingException(String msg) { + super(msg); + } +} \ No newline at end of file diff --git a/backend/src/main/java/de/szut/casino/security/AuthController.java b/backend/src/main/java/de/szut/casino/security/AuthController.java index d22de30..27949c5 100644 --- a/backend/src/main/java/de/szut/casino/security/AuthController.java +++ b/backend/src/main/java/de/szut/casino/security/AuthController.java @@ -3,6 +3,7 @@ package de.szut.casino.security; import de.szut.casino.exceptionHandling.ErrorDetails; import de.szut.casino.exceptionHandling.exceptions.EmailNotVerifiedException; import de.szut.casino.security.dto.AuthResponseDto; +import de.szut.casino.security.dto.GithubCallbackDto; import de.szut.casino.security.dto.LoginRequestDto; import de.szut.casino.security.dto.ResetPasswordDto; import de.szut.casino.security.service.AuthService; @@ -15,14 +16,17 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import java.io.IOException; -import java.util.Date; @RestController @RequestMapping("/auth") public class AuthController { + @Autowired private AuthService authService; + + @Autowired + private GitHubService githubService; @PostMapping("/login") public ResponseEntity authenticateUser(@Valid @RequestBody LoginRequestDto loginRequest) throws EmailNotVerifiedException { diff --git a/backend/src/main/java/de/szut/casino/security/GitHubController.java b/backend/src/main/java/de/szut/casino/security/GitHubController.java new file mode 100644 index 0000000..9c2f2bf --- /dev/null +++ b/backend/src/main/java/de/szut/casino/security/GitHubController.java @@ -0,0 +1,61 @@ +package de.szut.casino.security; + +import de.szut.casino.security.dto.AuthResponseDto; +import de.szut.casino.security.dto.GithubCallbackDto; +import de.szut.casino.security.jwt.JwtUtils; +import de.szut.casino.user.AuthProvider; +import de.szut.casino.user.UserEntity; +import de.szut.casino.user.UserRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.servlet.view.RedirectView; + +import java.math.BigDecimal; +import java.util.*; + +@RestController +@RequestMapping("/oauth2/github") +public class GitHubController { + private static final Logger logger = LoggerFactory.getLogger(GitHubController.class); + + @Value("${spring.security.oauth2.client.registration.github.client-id}") + private String clientId; + + @Value("${spring.security.oauth2.client.provider.github.authorization-uri}") + private String authorizationUri; + + @Value("${spring.security.oauth2.client.registration.github.redirect-uri}") + private String redirectUri; + + @Autowired + private GitHubService githubService; + + @GetMapping("/authorize") + public RedirectView authorizeGithub() { + logger.info("Redirecting to GitHub for authorization"); + + String authUrl = authorizationUri + + "?client_id=" + clientId + + "&redirect_uri=" + redirectUri + + "&scope=user:email,read:user"; + + return new RedirectView(authUrl); + } + + + @PostMapping("/callback") + public ResponseEntity githubCallback(@RequestBody GithubCallbackDto githubCallbackDto) { + String code = githubCallbackDto.getCode(); + AuthResponseDto response = githubService.processGithubCode(code); + return ResponseEntity.ok(response); + } +} \ No newline at end of file diff --git a/backend/src/main/java/de/szut/casino/security/GitHubService.java b/backend/src/main/java/de/szut/casino/security/GitHubService.java new file mode 100644 index 0000000..d6572d8 --- /dev/null +++ b/backend/src/main/java/de/szut/casino/security/GitHubService.java @@ -0,0 +1,196 @@ +package de.szut.casino.security; + +import de.szut.casino.security.dto.AuthResponseDto; +import de.szut.casino.security.jwt.JwtUtils; +import de.szut.casino.user.AuthProvider; +import de.szut.casino.user.UserEntity; +import de.szut.casino.user.UserRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +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.GrantedAuthority; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +import java.math.BigDecimal; +import java.util.*; + +@Service +public class GitHubService { + private static final Logger logger = LoggerFactory.getLogger(GitHubService.class); + + @Value("${spring.security.oauth2.client.registration.github.client-id}") + private String clientId; + + @Value("${spring.security.oauth2.client.registration.github.client-secret}") + private String clientSecret; + + @Autowired + private AuthenticationManager authenticationManager; + + @Autowired + private UserRepository userRepository; + + @Autowired + private JwtUtils jwtUtils; + + @Autowired + private PasswordEncoder oauth2PasswordEncoder; + + public AuthResponseDto processGithubCode(String code) { + try { + // Exchange code for access token + RestTemplate restTemplate = new RestTemplate(); + + // Create request body for token endpoint + Map requestBody = new HashMap<>(); + requestBody.put("client_id", clientId); + requestBody.put("client_secret", clientSecret); + requestBody.put("code", code); + + // Set headers + HttpHeaders headers = new HttpHeaders(); + headers.set("Accept", "application/json"); + + HttpEntity> requestEntity = new HttpEntity<>(requestBody, headers); + + // Get access token + ResponseEntity response = restTemplate.exchange( + "https://github.com/login/oauth/access_token", + HttpMethod.POST, + requestEntity, + Map.class + ); + + Map responseBody = response.getBody(); + logger.info("GitHub token response: {}", responseBody); + + // Check if there's an error in the response + if (responseBody.containsKey("error")) { + String error = (String) responseBody.get("error"); + String errorDescription = (String) responseBody.get("error_description"); + logger.error("GitHub OAuth error: {} - {}", error, errorDescription); + throw new RuntimeException("GitHub OAuth error: " + errorDescription); + } + + String accessToken = (String) responseBody.get("access_token"); + if (accessToken == null || accessToken.isEmpty()) { + logger.error("No access token received from GitHub"); + throw new RuntimeException("Failed to receive access token from GitHub"); + } + + logger.info("Received access token from GitHub"); + + // Get user info + HttpHeaders userInfoHeaders = new HttpHeaders(); + userInfoHeaders.set("Authorization", "Bearer " + accessToken); + + HttpEntity userInfoRequestEntity = new HttpEntity<>(null, userInfoHeaders); + + logger.info("Making request to GitHub API with token: {}", accessToken.substring(0, 5) + "..."); + + ResponseEntity userResponse = restTemplate.exchange( + "https://api.github.com/user", + HttpMethod.GET, + userInfoRequestEntity, + Map.class + ); + + Map userAttributes = userResponse.getBody(); + logger.info("Retrieved user info from GitHub: {}", userAttributes.get("login")); + + // Get user emails + HttpHeaders emailsHeaders = new HttpHeaders(); + emailsHeaders.set("Authorization", "Bearer " + accessToken); + + HttpEntity emailsRequestEntity = new HttpEntity<>(null, emailsHeaders); + + ResponseEntity emailsResponse = restTemplate.exchange( + "https://api.github.com/user/emails", + HttpMethod.GET, + emailsRequestEntity, + List.class + ); + + List> emails = emailsResponse.getBody(); + String email = null; + + // Find primary email + for (Map emailInfo : emails) { + Boolean primary = (Boolean) emailInfo.get("primary"); + if (primary != null && primary) { + email = (String) emailInfo.get("email"); + break; + } + } + + // If no primary email, just use the first one + if (email == null && !emails.isEmpty()) { + email = (String) emails.get(0).get("email"); + } + + logger.info("Using email: {}", email); + + // Process user data + String githubId = userAttributes.get("id").toString(); + String username = (String) userAttributes.get("login"); + + // Check if user exists by provider ID + Optional userOptional = userRepository.findByProviderId(githubId); + UserEntity user; + + if (userOptional.isPresent()) { + // Update existing user + user = userOptional.get(); + logger.info("Found existing user with providerId: {}", githubId); + } else { + // Check if email exists + userOptional = userRepository.findByEmail(email); + + if (userOptional.isPresent()) { + user = userOptional.get(); + user.setProvider(AuthProvider.GITHUB); + user.setProviderId(githubId); + logger.info("Updating existing user with email: {}", email); + } else { + // Create new user + user = new UserEntity(); + user.setEmail(email); + user.setUsername(username); + user.setProvider(AuthProvider.GITHUB); + user.setProviderId(githubId); + user.setEmailVerified(true); + + user.setBalance(new BigDecimal("1000.00")); + logger.info("Creating new user for: {}", username); + } + } + + String randomPassword = UUID.randomUUID().toString(); + user.setPassword(oauth2PasswordEncoder.encode(randomPassword)); + + userRepository.save(user); + + Authentication authentication = this.authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(user.getEmail(), randomPassword)); + + // Generate JWT token + String token = jwtUtils.generateToken(authentication); + logger.info("Generated JWT token"); + + return new AuthResponseDto(token); + + } catch (Exception e) { + logger.error("Error processing GitHub code", e); + throw new RuntimeException("Failed to process GitHub authentication", e); + } + } +} \ No newline at end of file diff --git a/backend/src/main/java/de/szut/casino/security/SecurityConfig.java b/backend/src/main/java/de/szut/casino/security/SecurityConfig.java index 155de8d..3efbec7 100644 --- a/backend/src/main/java/de/szut/casino/security/SecurityConfig.java +++ b/backend/src/main/java/de/szut/casino/security/SecurityConfig.java @@ -1,6 +1,8 @@ package de.szut.casino.security; import de.szut.casino.security.jwt.JwtAuthenticationFilter; +import de.szut.casino.security.oauth2.CustomOAuth2UserService; +import de.szut.casino.security.oauth2.OAuth2AuthenticationSuccessHandler; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; @@ -31,12 +33,16 @@ public class SecurityConfig { @Value("${app.frontend-host}") private String frontendHost; + + @Value("${app.oauth2.authorizedRedirectUris}") + private String authorizedRedirectUri; @Autowired private UserDetailsService userDetailsService; @Autowired private JwtAuthenticationFilter jwtAuthenticationFilter; + @Bean public DaoAuthenticationProvider authenticationProvider() { @@ -65,10 +71,12 @@ public class SecurityConfig { .csrf(csrf -> csrf.disable()) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> { - auth.requestMatchers("/auth/**", "/webhook", "/swagger/**", "/swagger-ui/**", "/health", "/error").permitAll() + auth.requestMatchers("/auth/**", "/webhook", "/swagger/**", "/swagger-ui/**", "/health", "/error", "/oauth2/**").permitAll() .requestMatchers(org.springframework.http.HttpMethod.OPTIONS, "/**").permitAll() .anyRequest().authenticated(); }) + // Disable Spring's built-in OAuth2 login since we're implementing a custom flow + // We're using our own GitHubController for OAuth2 login .authenticationProvider(authenticationProvider()) .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); diff --git a/backend/src/main/java/de/szut/casino/security/dto/GithubCallbackDto.java b/backend/src/main/java/de/szut/casino/security/dto/GithubCallbackDto.java new file mode 100644 index 0000000..7d0315d --- /dev/null +++ b/backend/src/main/java/de/szut/casino/security/dto/GithubCallbackDto.java @@ -0,0 +1,8 @@ +package de.szut.casino.security.dto; + +import lombok.Data; + +@Data +public class GithubCallbackDto { + private String code; +} \ No newline at end of file diff --git a/backend/src/main/java/de/szut/casino/security/jwt/JwtUtils.java b/backend/src/main/java/de/szut/casino/security/jwt/JwtUtils.java index 3c5ef86..1a7d08d 100644 --- a/backend/src/main/java/de/szut/casino/security/jwt/JwtUtils.java +++ b/backend/src/main/java/de/szut/casino/security/jwt/JwtUtils.java @@ -1,12 +1,16 @@ package de.szut.casino.security.jwt; +import de.szut.casino.security.oauth2.UserPrincipal; import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; import io.jsonwebtoken.security.Keys; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.security.core.Authentication; import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.stereotype.Component; import java.security.Key; @@ -17,6 +21,7 @@ import java.util.function.Function; @Component public class JwtUtils { + private static final Logger logger = LoggerFactory.getLogger(JwtUtils.class); @Value("${jwt.secret}") private String jwtSecret; @@ -29,8 +34,26 @@ public class JwtUtils { } public String generateToken(Authentication authentication) { - UserDetails userDetails = (UserDetails) authentication.getPrincipal(); - return generateToken(userDetails.getUsername()); + String subject = null; + Map claims = new HashMap<>(); + + if (authentication.getPrincipal() instanceof UserPrincipal) { + UserPrincipal userPrincipal = (UserPrincipal) authentication.getPrincipal(); + subject = userPrincipal.getEmail(); + claims.put("id", userPrincipal.getId()); + claims.put("username", userPrincipal.getDisplayUsername()); + logger.info("Generating token for UserPrincipal: {}", subject); + } else if (authentication.getPrincipal() instanceof OAuth2User) { + OAuth2User oauth2User = (OAuth2User) authentication.getPrincipal(); + subject = (String) oauth2User.getAttributes().get("email"); + logger.info("Generating token for OAuth2User: {}", subject); + } else { + UserDetails userDetails = (UserDetails) authentication.getPrincipal(); + subject = userDetails.getUsername(); + logger.info("Generating token for UserDetails: {}", subject); + } + + return createToken(claims, subject); } public String generateToken(String username) { @@ -40,6 +63,9 @@ public class JwtUtils { private String createToken(Map claims, String subject) { Date now = new Date(); + logger.info("now: {}", now); + logger.info("jwtExpirationMs: {}", jwtExpirationMs); + logger.info("expiryDate: {}", new Date(now.getTime() + jwtExpirationMs)); Date expiryDate = new Date(now.getTime() + jwtExpirationMs); return Jwts.builder() diff --git a/backend/src/main/java/de/szut/casino/security/oauth2/CustomOAuth2UserService.java b/backend/src/main/java/de/szut/casino/security/oauth2/CustomOAuth2UserService.java new file mode 100644 index 0000000..51f52d8 --- /dev/null +++ b/backend/src/main/java/de/szut/casino/security/oauth2/CustomOAuth2UserService.java @@ -0,0 +1,108 @@ +package de.szut.casino.security.oauth2; + +import de.szut.casino.exceptionHandling.exceptions.OAuth2AuthenticationProcessingException; +import de.szut.casino.user.AuthProvider; +import de.szut.casino.user.UserEntity; +import de.szut.casino.user.UserRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.authentication.InternalAuthenticationServiceException; +import org.springframework.security.core.AuthenticationException; +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.user.OAuth2User; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import java.math.BigDecimal; +import java.util.Optional; +import java.util.UUID; + +@Service +public class CustomOAuth2UserService extends DefaultOAuth2UserService { + + @Autowired + private UserRepository userRepository; + + @Autowired + private PasswordEncoder oauth2PasswordEncoder; + + @Override + public OAuth2User loadUser(OAuth2UserRequest oAuth2UserRequest) throws OAuth2AuthenticationException { + OAuth2User oAuth2User = super.loadUser(oAuth2UserRequest); + + try { + return processOAuth2User(oAuth2UserRequest, oAuth2User); + } catch (AuthenticationException ex) { + throw ex; + } catch (Exception ex) { + throw new InternalAuthenticationServiceException(ex.getMessage(), ex.getCause()); + } + } + + private OAuth2User processOAuth2User(OAuth2UserRequest oAuth2UserRequest, OAuth2User oAuth2User) { + String registrationId = oAuth2UserRequest.getClientRegistration().getRegistrationId(); + OAuth2UserInfo oAuth2UserInfo = OAuth2UserInfoFactory.getOAuth2UserInfo(registrationId, oAuth2User.getAttributes()); + + // For GitHub, the email might not be directly available in attributes + String email = oAuth2UserInfo.getEmail(); + if (StringUtils.isEmpty(email)) { + email = oAuth2UserInfo.getName() + "@github.user"; + } + + Optional userOptional = userRepository.findByEmail(email); + UserEntity user; + + if(userOptional.isPresent()) { + user = userOptional.get(); + + if(!user.getProvider().equals(AuthProvider.valueOf(registrationId.toUpperCase()))) { + throw new OAuth2AuthenticationProcessingException("You're signed up with " + + user.getProvider() + ". Please use your " + user.getProvider() + + " account to login."); + } + + user = updateExistingUser(user, oAuth2UserInfo); + } else { + user = registerNewUser(oAuth2UserRequest, oAuth2UserInfo, email); + } + + return UserPrincipal.create(user, oAuth2User.getAttributes()); + } + + private UserEntity registerNewUser(OAuth2UserRequest oAuth2UserRequest, OAuth2UserInfo oAuth2UserInfo, String email) { + UserEntity user = new UserEntity(); + + String username = oAuth2UserInfo.getName(); + if (StringUtils.isEmpty(username)) { + username = "github_" + oAuth2UserInfo.getId(); + } + + // Check if username already exists and append a suffix if needed + if (userRepository.findByUsername(username).isPresent()) { + username = username + "_" + UUID.randomUUID().toString().substring(0, 8); + } + + user.setProvider(AuthProvider.valueOf(oAuth2UserRequest.getClientRegistration().getRegistrationId().toUpperCase())); + user.setProviderId(oAuth2UserInfo.getId()); + user.setUsername(username); + user.setEmail(email); + user.setEmailVerified(true); + + // Generate a random password for OAuth users (they won't use it) + String randomPassword = UUID.randomUUID().toString(); + user.setPassword(oauth2PasswordEncoder.encode(randomPassword)); + + user.setBalance(new BigDecimal("1000.00")); // Starting balance + + return userRepository.save(user); + } + + private UserEntity updateExistingUser(UserEntity existingUser, OAuth2UserInfo oAuth2UserInfo) { + if (!StringUtils.isEmpty(oAuth2UserInfo.getName())) { + existingUser.setUsername(oAuth2UserInfo.getName()); + } + return userRepository.save(existingUser); + } +} \ No newline at end of file diff --git a/backend/src/main/java/de/szut/casino/security/oauth2/GitHubOAuth2UserInfo.java b/backend/src/main/java/de/szut/casino/security/oauth2/GitHubOAuth2UserInfo.java new file mode 100644 index 0000000..2646956 --- /dev/null +++ b/backend/src/main/java/de/szut/casino/security/oauth2/GitHubOAuth2UserInfo.java @@ -0,0 +1,30 @@ +package de.szut.casino.security.oauth2; + +import java.util.Map; + +public class GitHubOAuth2UserInfo extends OAuth2UserInfo { + + public GitHubOAuth2UserInfo(Map attributes) { + super(attributes); + } + + @Override + public String getId() { + return ((Integer) attributes.get("id")).toString(); + } + + @Override + public String getName() { + return (String) attributes.get("name"); + } + + @Override + public String getEmail() { + return (String) attributes.get("email"); + } + + @Override + public String getImageUrl() { + return (String) attributes.get("avatar_url"); + } +} \ No newline at end of file diff --git a/backend/src/main/java/de/szut/casino/security/oauth2/OAuth2AuthenticationSuccessHandler.java b/backend/src/main/java/de/szut/casino/security/oauth2/OAuth2AuthenticationSuccessHandler.java new file mode 100644 index 0000000..00d0b3e --- /dev/null +++ b/backend/src/main/java/de/szut/casino/security/oauth2/OAuth2AuthenticationSuccessHandler.java @@ -0,0 +1,61 @@ +package de.szut.casino.security.oauth2; + +import de.szut.casino.security.jwt.JwtUtils; +import de.szut.casino.user.UserEntity; +import de.szut.casino.user.UserRepository; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; +import org.springframework.stereotype.Component; +import org.springframework.web.util.UriComponentsBuilder; + +import java.io.IOException; + +@Component +public class OAuth2AuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler { + private static final Logger logger = LoggerFactory.getLogger(OAuth2AuthenticationSuccessHandler.class); + + @Value("${app.oauth2.authorizedRedirectUris}") + private String redirectUri; + + @Autowired + private JwtUtils jwtUtils; + + @Autowired + private UserRepository userRepository; + + @Override + public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) + throws IOException, ServletException { + String targetUrl = determineTargetUrl(authentication); + + logger.info("OAuth2 Authentication successful, redirecting to: {}", targetUrl); + + if (response.isCommitted()) { + logger.debug("Response has already been committed. Unable to redirect to " + targetUrl); + return; + } + + clearAuthenticationAttributes(request); + getRedirectStrategy().sendRedirect(request, response, targetUrl); + } + + private String determineTargetUrl(Authentication authentication) { + String token = jwtUtils.generateToken(authentication); + + if (authentication.getPrincipal() instanceof UserPrincipal) { + UserPrincipal userPrincipal = (UserPrincipal) authentication.getPrincipal(); + logger.info("User authenticated: ID={}, Email={}", userPrincipal.getId(), userPrincipal.getEmail()); + } + + return UriComponentsBuilder.fromUriString(redirectUri) + .queryParam("token", token) + .build().toUriString(); + } +} \ No newline at end of file diff --git a/backend/src/main/java/de/szut/casino/security/oauth2/OAuth2Config.java b/backend/src/main/java/de/szut/casino/security/oauth2/OAuth2Config.java new file mode 100644 index 0000000..224d9c0 --- /dev/null +++ b/backend/src/main/java/de/szut/casino/security/oauth2/OAuth2Config.java @@ -0,0 +1,15 @@ +package de.szut.casino.security.oauth2; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; + +@Configuration +public class OAuth2Config { + + @Bean + public PasswordEncoder oauth2PasswordEncoder() { + return new BCryptPasswordEncoder(); + } +} \ No newline at end of file diff --git a/backend/src/main/java/de/szut/casino/security/oauth2/OAuth2UserInfo.java b/backend/src/main/java/de/szut/casino/security/oauth2/OAuth2UserInfo.java new file mode 100644 index 0000000..3acd762 --- /dev/null +++ b/backend/src/main/java/de/szut/casino/security/oauth2/OAuth2UserInfo.java @@ -0,0 +1,23 @@ +package de.szut.casino.security.oauth2; + +import java.util.Map; + +public abstract class OAuth2UserInfo { + protected Map attributes; + + public OAuth2UserInfo(Map attributes) { + this.attributes = attributes; + } + + public Map getAttributes() { + return attributes; + } + + public abstract String getId(); + + public abstract String getName(); + + public abstract String getEmail(); + + public abstract String getImageUrl(); +} \ No newline at end of file diff --git a/backend/src/main/java/de/szut/casino/security/oauth2/OAuth2UserInfoFactory.java b/backend/src/main/java/de/szut/casino/security/oauth2/OAuth2UserInfoFactory.java new file mode 100644 index 0000000..0d3c068 --- /dev/null +++ b/backend/src/main/java/de/szut/casino/security/oauth2/OAuth2UserInfoFactory.java @@ -0,0 +1,17 @@ +package de.szut.casino.security.oauth2; + +import de.szut.casino.exceptionHandling.exceptions.OAuth2AuthenticationProcessingException; +import de.szut.casino.user.AuthProvider; + +import java.util.Map; + +public class OAuth2UserInfoFactory { + + public static OAuth2UserInfo getOAuth2UserInfo(String registrationId, Map attributes) { + if(registrationId.equalsIgnoreCase(AuthProvider.GITHUB.toString())) { + return new GitHubOAuth2UserInfo(attributes); + } else { + throw new OAuth2AuthenticationProcessingException("Sorry! Login with " + registrationId + " is not supported yet."); + } + } +} \ No newline at end of file diff --git a/backend/src/main/java/de/szut/casino/security/oauth2/UserPrincipal.java b/backend/src/main/java/de/szut/casino/security/oauth2/UserPrincipal.java new file mode 100644 index 0000000..113b945 --- /dev/null +++ b/backend/src/main/java/de/szut/casino/security/oauth2/UserPrincipal.java @@ -0,0 +1,110 @@ +package de.szut.casino.security.oauth2; + +import de.szut.casino.user.UserEntity; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.oauth2.core.user.OAuth2User; + +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +public class UserPrincipal implements OAuth2User, UserDetails { + private Long id; + private String email; + private String username; + private String password; + private Collection authorities; + private Map attributes; + + public UserPrincipal(Long id, String email, String username, String password, Collection authorities) { + this.id = id; + this.email = email; + this.username = username; + this.password = password; + this.authorities = authorities; + } + + public static UserPrincipal create(UserEntity user) { + List authorities = Collections. + singletonList(new SimpleGrantedAuthority("ROLE_USER")); + + return new UserPrincipal( + user.getId(), + user.getEmail(), + user.getUsername(), + user.getPassword(), + authorities + ); + } + + public static UserPrincipal create(UserEntity user, Map attributes) { + UserPrincipal userPrincipal = UserPrincipal.create(user); + userPrincipal.setAttributes(attributes); + return userPrincipal; + } + + public Long getId() { + return id; + } + + public String getEmail() { + return email; + } + + @Override + public String getPassword() { + return password; + } + + @Override + public String getUsername() { + // We're using email as the username for authentication + return email; + } + + public String getDisplayUsername() { + return username; + } + + @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 Collection getAuthorities() { + return authorities; + } + + @Override + public Map getAttributes() { + return attributes; + } + + public void setAttributes(Map attributes) { + this.attributes = attributes; + } + + @Override + public String getName() { + return String.valueOf(id); + } +} \ No newline at end of file diff --git a/backend/src/main/java/de/szut/casino/security/service/AuthService.java b/backend/src/main/java/de/szut/casino/security/service/AuthService.java index 959a55a..ffe06ae 100644 --- a/backend/src/main/java/de/szut/casino/security/service/AuthService.java +++ b/backend/src/main/java/de/szut/casino/security/service/AuthService.java @@ -19,7 +19,6 @@ import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; -import javax.swing.text.html.Option; import java.io.IOException; import java.util.Optional; diff --git a/backend/src/main/java/de/szut/casino/user/AuthProvider.java b/backend/src/main/java/de/szut/casino/user/AuthProvider.java new file mode 100644 index 0000000..d7c7e05 --- /dev/null +++ b/backend/src/main/java/de/szut/casino/user/AuthProvider.java @@ -0,0 +1,6 @@ +package de.szut.casino.user; + +public enum AuthProvider { + LOCAL, + GITHUB +} \ No newline at end of file diff --git a/backend/src/main/java/de/szut/casino/user/UserEntity.java b/backend/src/main/java/de/szut/casino/user/UserEntity.java index 2190867..fa53b9a 100644 --- a/backend/src/main/java/de/szut/casino/user/UserEntity.java +++ b/backend/src/main/java/de/szut/casino/user/UserEntity.java @@ -2,6 +2,8 @@ package de.szut.casino.user; import jakarta.persistence.Column; import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; import jakarta.persistence.GeneratedValue; import jakarta.persistence.Id; import lombok.Getter; @@ -35,6 +37,11 @@ public class UserEntity { private String verificationToken; private String passwordResetToken; + + @Enumerated(EnumType.STRING) + private AuthProvider provider = AuthProvider.LOCAL; + + private String providerId; public UserEntity(String email, String username, String password, BigDecimal balance, String verificationToken) { this.email = email; @@ -42,6 +49,16 @@ public class UserEntity { this.password = password; this.balance = balance; this.verificationToken = verificationToken; + this.provider = AuthProvider.LOCAL; + } + + public UserEntity(String email, String username, AuthProvider provider, String providerId, BigDecimal balance) { + this.email = email; + this.username = username; + this.provider = provider; + this.providerId = providerId; + this.balance = balance; + this.emailVerified = true; // OAuth providers verify emails } public void addBalance(BigDecimal amountToAdd) { diff --git a/backend/src/main/java/de/szut/casino/user/UserRepository.java b/backend/src/main/java/de/szut/casino/user/UserRepository.java index 29790f5..36673b1 100644 --- a/backend/src/main/java/de/szut/casino/user/UserRepository.java +++ b/backend/src/main/java/de/szut/casino/user/UserRepository.java @@ -12,6 +12,8 @@ public interface UserRepository extends JpaRepository { Optional findByEmail(String email); + Optional findByProviderId(String providerId); + boolean existsByUsername(String username); boolean existsByEmail(String email); diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index 0eaca18..e583c50 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -28,3 +28,16 @@ logging.level.org.springframework.security=DEBUG springdoc.swagger-ui.path=swagger springdoc.swagger-ui.try-it-out-enabled=true +# GitHub OAuth2 Configuration +spring.security.oauth2.client.registration.github.client-id=${GITHUB_CLIENT_ID:Ov23lingzZsPn1wwACoK} +spring.security.oauth2.client.registration.github.client-secret=${GITHUB_CLIENT_SECRET:4b327fb3b1ab67584a03bcb9d53fa6439fbccad7} +spring.security.oauth2.client.registration.github.redirect-uri=${app.frontend-host}/oauth2/callback/github +spring.security.oauth2.client.registration.github.scope=user:email,read:user +spring.security.oauth2.client.provider.github.authorization-uri=https://github.com/login/oauth/authorize +spring.security.oauth2.client.provider.github.token-uri=https://github.com/login/oauth/access_token +spring.security.oauth2.client.provider.github.user-info-uri=https://api.github.com/user +spring.security.oauth2.client.provider.github.user-name-attribute=login + +# OAuth Success and Failure URLs +app.oauth2.authorizedRedirectUris=${app.frontend-host}/auth/oauth2/callback + diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts index b4bf818..39c5b1c 100644 --- a/frontend/src/app/app.routes.ts +++ b/frontend/src/app/app.routes.ts @@ -33,6 +33,13 @@ export const routes: Routes = [ (m) => m.RecoverPasswordComponent ), }, + { + path: 'oauth2/callback/github', + loadComponent: () => + import('./feature/auth/oauth2/oauth2-callback.component').then( + (m) => m.OAuth2CallbackComponent + ), + }, { path: 'game/blackjack', loadComponent: () => import('./feature/game/blackjack/blackjack.component'), diff --git a/frontend/src/app/feature/auth/login/login.component.html b/frontend/src/app/feature/auth/login/login.component.html index 04afd42..fee6ce9 100644 --- a/frontend/src/app/feature/auth/login/login.component.html +++ b/frontend/src/app/feature/auth/login/login.component.html @@ -82,8 +82,26 @@ + +
+
+ ODER +
+
+ +
+ +
-
+

Passwort vergessen?

+ `, +}) +export class OAuth2CallbackComponent implements OnInit { + error: string | null = null; + + constructor( + private route: ActivatedRoute, + private router: Router, + private authService: AuthService + ) {} + + ngOnInit(): void { + // Check for code in URL params + this.route.queryParams.subscribe(params => { + const code = params['code']; + + if (code) { + // Exchange GitHub code for a JWT token + this.authService.githubAuth(code).subscribe({ + next: () => { + // Redirect to home after successful authentication + this.router.navigate(['/home']); + }, + error: (err) => { + console.error('GitHub authentication error:', err); + this.error = err.error?.message || "Authentication failed. Please try again."; + console.log('Error details:', err); + + // Redirect back to landing page after showing error + setTimeout(() => { + this.router.navigate(['/']); + }, 3000); + } + }); + } else { + this.error = "Authentication failed. No authorization code received."; + + // Redirect back to landing page after showing error + setTimeout(() => { + this.router.navigate(['/']); + }, 3000); + } + }); + } +} diff --git a/frontend/src/app/service/auth.service.ts b/frontend/src/app/service/auth.service.ts index f1cfbe9..c517d88 100644 --- a/frontend/src/app/service/auth.service.ts +++ b/frontend/src/app/service/auth.service.ts @@ -1,7 +1,7 @@ import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { BehaviorSubject, Observable, tap } from 'rxjs'; -import { Router } from '@angular/router'; +import { Router, ActivatedRoute } from '@angular/router'; import { LoginRequest } from '../model/auth/LoginRequest'; import { RegisterRequest } from '../model/auth/RegisterRequest'; import { AuthResponse } from '../model/auth/AuthResponse'; @@ -17,20 +17,41 @@ const USER_KEY = 'user'; export class AuthService { private authUrl = `${environment.apiUrl}/auth`; private userUrl = `${environment.apiUrl}/users`; + private oauthUrl = `${environment.apiUrl}/oauth2`; userSubject: BehaviorSubject; constructor( private http: HttpClient, - private router: Router + private router: Router, + private route: ActivatedRoute ) { this.userSubject = new BehaviorSubject(this.getUserFromStorage()); + // Check for token in URL (OAuth callback) on initialization + this.route.queryParams.subscribe(params => { + const token = params['token']; + if (token) { + this.handleOAuthCallback(token); + } + }); + if (this.getToken()) { this.loadCurrentUser(); } } + private handleOAuthCallback(token: string): void { + this.setToken(token); + this.loadCurrentUser(); + // Clean up the URL by removing the token + this.router.navigate([], { + relativeTo: this.route, + queryParams: {}, + replaceUrl: true + }); + } + public get currentUserValue(): User | null { return this.userSubject.value; } @@ -48,6 +69,16 @@ export class AuthService { return this.http.post(`${this.authUrl}/register`, registerRequest); } + githubAuth(code: string): Observable { + return this.http.post(`${this.oauthUrl}/github/callback`, { code }).pipe( + tap((response) => { + console.log(response.token); + this.setToken(response.token); + this.loadCurrentUser(); + }) + ); + } + logout(): void { localStorage.removeItem(TOKEN_KEY); localStorage.removeItem(USER_KEY);