diff --git a/backend/build.gradle.kts b/backend/build.gradle.kts index 51e3cd6..a0b6899 100644 --- a/backend/build.gradle.kts +++ b/backend/build.gradle.kts @@ -51,6 +51,9 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-oauth2-client:3.4.5") runtimeOnly("org.postgresql:postgresql") implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.8") + implementation("io.jsonwebtoken:jjwt-api:0.11.5") + runtimeOnly("io.jsonwebtoken:jjwt-impl:0.11.5") + runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.11.5") } tasks.withType { diff --git a/backend/src/main/java/de/szut/casino/CasinoApplication.java b/backend/src/main/java/de/szut/casino/CasinoApplication.java index 1d7e861..2818f23 100644 --- a/backend/src/main/java/de/szut/casino/CasinoApplication.java +++ b/backend/src/main/java/de/szut/casino/CasinoApplication.java @@ -13,7 +13,6 @@ import org.springframework.web.client.RestTemplate; import java.math.BigDecimal; import java.util.ArrayList; import java.util.Arrays; -import java.util.List; @SpringBootApplication public class CasinoApplication { diff --git a/backend/src/main/java/de/szut/casino/blackjack/BlackJackGameController.java b/backend/src/main/java/de/szut/casino/blackjack/BlackJackGameController.java index 6813236..d4c0e6e 100644 --- a/backend/src/main/java/de/szut/casino/blackjack/BlackJackGameController.java +++ b/backend/src/main/java/de/szut/casino/blackjack/BlackJackGameController.java @@ -11,8 +11,8 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; -import java.math.BigDecimal; -import java.util.*; +import java.util.Objects; +import java.util.Optional; @Slf4j @RestController @@ -29,8 +29,8 @@ public class BlackJackGameController { } @GetMapping("/blackjack/{id}") - public ResponseEntity getGame(@PathVariable Long id, @RequestHeader("Authorization") String token) { - Optional optionalUser = userService.getCurrentUser(token); + public ResponseEntity getGame(@PathVariable Long id) { + Optional optionalUser = userService.getCurrentUser(); if (optionalUser.isEmpty()) { throw new UserNotFoundException(); @@ -46,8 +46,8 @@ public class BlackJackGameController { } @PostMapping("/blackjack/{id}/hit") - public ResponseEntity hit(@PathVariable Long id, @RequestHeader("Authorization") String token) { - Optional optionalUser = userService.getCurrentUser(token); + public ResponseEntity hit(@PathVariable Long id) { + Optional optionalUser = userService.getCurrentUser(); if (optionalUser.isEmpty()) { throw new UserNotFoundException(); @@ -63,8 +63,8 @@ public class BlackJackGameController { } @PostMapping("/blackjack/{id}/stand") - public ResponseEntity stand(@PathVariable Long id, @RequestHeader("Authorization") String token) { - Optional optionalUser = userService.getCurrentUser(token); + public ResponseEntity stand(@PathVariable Long id) { + Optional optionalUser = userService.getCurrentUser(); if (optionalUser.isEmpty()) { throw new UserNotFoundException(); @@ -80,8 +80,8 @@ public class BlackJackGameController { } @PostMapping("/blackjack/{id}/doubleDown") - public ResponseEntity doubleDown(@PathVariable Long id, @RequestHeader("Authorization") String token) { - Optional optionalUser = userService.getCurrentUser(token); + public ResponseEntity doubleDown(@PathVariable Long id) { + Optional optionalUser = userService.getCurrentUser(); if (optionalUser.isEmpty()) { throw new UserNotFoundException(); @@ -97,8 +97,8 @@ public class BlackJackGameController { } @PostMapping("/blackjack/{id}/split") - public ResponseEntity split(@PathVariable Long id, @RequestHeader("Authorization") String token) { - Optional optionalUser = userService.getCurrentUser(token); + public ResponseEntity split(@PathVariable Long id) { + Optional optionalUser = userService.getCurrentUser(); if (optionalUser.isEmpty()) { throw new UserNotFoundException(); @@ -114,8 +114,8 @@ public class BlackJackGameController { } @PostMapping("/blackjack/start") - public ResponseEntity createBlackJackGame(@RequestBody @Valid BetDto betDto, @RequestHeader("Authorization") String token) { - Optional optionalUser = userService.getCurrentUser(token); + public ResponseEntity createBlackJackGame(@RequestBody @Valid BetDto betDto) { + Optional optionalUser = userService.getCurrentUser(); if (optionalUser.isEmpty()) { throw new UserNotFoundException(); diff --git a/backend/src/main/java/de/szut/casino/config/WebConfig.java b/backend/src/main/java/de/szut/casino/config/WebConfig.java new file mode 100644 index 0000000..bb11293 --- /dev/null +++ b/backend/src/main/java/de/szut/casino/config/WebConfig.java @@ -0,0 +1,30 @@ +package de.szut.casino.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class WebConfig { + + @Value("${app.frontend-host}") + private String frontendHost; + + @Bean + public WebMvcConfigurer corsConfigurer() { + return new WebMvcConfigurer() { + @Override + public void addCorsMappings(CorsRegistry registry) { + registry.addMapping("/**") + .allowedOrigins(frontendHost) + .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") + .allowedHeaders("*") + .exposedHeaders("*") + .allowCredentials(true) + .maxAge(3600); + } + }; + } +} diff --git a/backend/src/main/java/de/szut/casino/deposit/DepositController.java b/backend/src/main/java/de/szut/casino/deposit/DepositController.java index dbfe449..522e47d 100644 --- a/backend/src/main/java/de/szut/casino/deposit/DepositController.java +++ b/backend/src/main/java/de/szut/casino/deposit/DepositController.java @@ -7,19 +7,14 @@ import com.stripe.param.checkout.SessionCreateParams; import de.szut.casino.deposit.dto.AmountDto; import de.szut.casino.deposit.dto.SessionIdDto; import de.szut.casino.user.UserEntity; -import de.szut.casino.user.UserRepository; -import de.szut.casino.user.dto.KeycloakUserDto; +import de.szut.casino.user.UserService; import jakarta.validation.Valid; 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.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RestController; -import org.springframework.web.client.RestTemplate; import java.util.Optional; @@ -34,23 +29,18 @@ public class DepositController { private final TransactionService transactionService; - private final RestTemplate restTemplate; + private UserService userService; - private final UserRepository userRepository; - - - public DepositController(TransactionService transactionService, RestTemplate restTemplate, UserRepository userRepository) { + public DepositController(TransactionService transactionService, UserService userService) { this.transactionService = transactionService; - this.restTemplate = restTemplate; - this.userRepository = userRepository; + this.userService = userService; } @PostMapping("/deposit/checkout") public ResponseEntity checkout(@RequestBody @Valid AmountDto amountDto, @RequestHeader("Authorization") String token) throws StripeException { Stripe.apiKey = stripeKey; - KeycloakUserDto userData = getAuthentikUserInfo(token); - Optional optionalUserEntity = this.userRepository.findOneByAuthentikId(userData.getSub()); + Optional optionalUserEntity = this.userService.getCurrentUser(); SessionCreateParams params = SessionCreateParams.builder() .addLineItem(SessionCreateParams.LineItem.builder() @@ -78,13 +68,5 @@ public class DepositController { return ResponseEntity.ok(new SessionIdDto(session.getId())); } - - private KeycloakUserDto getAuthentikUserInfo(String token) { - HttpHeaders headers = new HttpHeaders(); - headers.set("Authorization", token); - ResponseEntity response = this.restTemplate.exchange("https://oauth.simonis.lol/application/o/userinfo/", HttpMethod.GET, new HttpEntity<>(headers), KeycloakUserDto.class); - - return response.getBody(); - } } diff --git a/backend/src/main/java/de/szut/casino/exceptionHandling/exceptions/UserNotFoundException.java b/backend/src/main/java/de/szut/casino/exceptionHandling/exceptions/UserNotFoundException.java index 6916a66..d843af7 100644 --- a/backend/src/main/java/de/szut/casino/exceptionHandling/exceptions/UserNotFoundException.java +++ b/backend/src/main/java/de/szut/casino/exceptionHandling/exceptions/UserNotFoundException.java @@ -6,6 +6,6 @@ import org.springframework.web.bind.annotation.ResponseStatus; @ResponseStatus(value = HttpStatus.NOT_FOUND) public class UserNotFoundException extends RuntimeException { public UserNotFoundException() { - super("user not found"); + super("User not found"); } } diff --git a/backend/src/main/java/de/szut/casino/lootboxes/LootBoxController.java b/backend/src/main/java/de/szut/casino/lootboxes/LootBoxController.java index 4ef8247..540e4c5 100644 --- a/backend/src/main/java/de/szut/casino/lootboxes/LootBoxController.java +++ b/backend/src/main/java/de/szut/casino/lootboxes/LootBoxController.java @@ -3,13 +3,15 @@ package de.szut.casino.lootboxes; import de.szut.casino.exceptionHandling.exceptions.InsufficientFundsException; import de.szut.casino.exceptionHandling.exceptions.UserNotFoundException; import de.szut.casino.user.UserEntity; -import de.szut.casino.user.UserRepository; import de.szut.casino.user.UserService; import jakarta.validation.Valid; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; -import java.util.*; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Optional; @RestController public class LootBoxController { @@ -17,7 +19,7 @@ public class LootBoxController { private final UserService userService; private final LootBoxService lootBoxService; - public LootBoxController(LootBoxRepository lootBoxRepository, UserRepository userRepository, UserService userService, LootBoxService lootBoxService) { + public LootBoxController(LootBoxRepository lootBoxRepository, UserService userService, LootBoxService lootBoxService) { this.lootBoxRepository = lootBoxRepository; this.userService = userService; this.lootBoxService = lootBoxService; @@ -29,7 +31,7 @@ public class LootBoxController { } @PostMapping("/lootboxes/{id}") - public ResponseEntity purchaseLootBox(@PathVariable Long id, @RequestHeader("Authorization") String token) { + public ResponseEntity purchaseLootBox(@PathVariable Long id) { Optional optionalLootBox = lootBoxRepository.findById(id); if (optionalLootBox.isEmpty()) { return ResponseEntity.notFound().build(); @@ -37,7 +39,7 @@ public class LootBoxController { LootBoxEntity lootBox = optionalLootBox.get(); - Optional optionalUser = userService.getCurrentUser(token); + Optional optionalUser = userService.getCurrentUser(); if (optionalUser.isEmpty()) { throw new UserNotFoundException(); } diff --git a/backend/src/main/java/de/szut/casino/security/AuthController.java b/backend/src/main/java/de/szut/casino/security/AuthController.java new file mode 100644 index 0000000..b5c0639 --- /dev/null +++ b/backend/src/main/java/de/szut/casino/security/AuthController.java @@ -0,0 +1,34 @@ +package de.szut.casino.security; + +import de.szut.casino.security.dto.AuthResponseDto; +import de.szut.casino.security.dto.LoginRequestDto; +import de.szut.casino.security.service.AuthService; +import de.szut.casino.user.dto.CreateUserDto; +import de.szut.casino.user.dto.GetUserDto; +import jakarta.validation.Valid; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/auth") +public class AuthController { + + @Autowired + private AuthService authService; + + @PostMapping("/login") + public ResponseEntity authenticateUser(@Valid @RequestBody LoginRequestDto loginRequest) { + AuthResponseDto response = authService.login(loginRequest); + return ResponseEntity.ok(response); + } + + @PostMapping("/register") + public ResponseEntity registerUser(@Valid @RequestBody CreateUserDto signUpRequest) { + GetUserDto response = authService.register(signUpRequest); + return ResponseEntity.ok(response); + } +} diff --git a/backend/src/main/java/de/szut/casino/security/CorsFilter.java b/backend/src/main/java/de/szut/casino/security/CorsFilter.java new file mode 100644 index 0000000..032088f --- /dev/null +++ b/backend/src/main/java/de/szut/casino/security/CorsFilter.java @@ -0,0 +1,41 @@ +package de.szut.casino.security; + +import jakarta.servlet.*; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +@Order(Ordered.HIGHEST_PRECEDENCE) +public class CorsFilter implements Filter { + + @Value("${app.frontend-host}") + private String frontendHost; + + @Override + public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException { + + HttpServletResponse response = (HttpServletResponse) res; + HttpServletRequest request = (HttpServletRequest) req; + + // Allow requests from the frontend + response.setHeader("Access-Control-Allow-Origin", frontendHost); + response.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS"); + response.setHeader("Access-Control-Allow-Headers", "*"); + response.setHeader("Access-Control-Expose-Headers", "*"); + response.setHeader("Access-Control-Allow-Credentials", "true"); + response.setHeader("Access-Control-Max-Age", "3600"); + + if ("OPTIONS".equalsIgnoreCase(request.getMethod())) { + response.setStatus(HttpServletResponse.SC_OK); + return; + } + + chain.doFilter(req, res); + } +} 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 65d5b2c..155de8d 100644 --- a/backend/src/main/java/de/szut/casino/security/SecurityConfig.java +++ b/backend/src/main/java/de/szut/casino/security/SecurityConfig.java @@ -1,12 +1,22 @@ package de.szut.casino.security; +import de.szut.casino.security.jwt.JwtAuthenticationFilter; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.security.config.Customizer; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.dao.DaoAuthenticationProvider; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; +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.http.SessionCreationPolicy; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; @@ -16,23 +26,51 @@ import java.util.List; @Configuration @EnableWebSecurity +@EnableMethodSecurity public class SecurityConfig { @Value("${app.frontend-host}") private String frontendHost; + @Autowired + private UserDetailsService userDetailsService; + + @Autowired + private JwtAuthenticationFilter jwtAuthenticationFilter; + + @Bean + public DaoAuthenticationProvider authenticationProvider() { + DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider(); + + authProvider.setUserDetailsService(userDetailsService); + authProvider.setPasswordEncoder(passwordEncoder()); + + return authProvider; + } + + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration authConfig) throws Exception { + return authConfig.getAuthenticationManager(); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http - .cors(Customizer.withDefaults()) + .cors(cors -> cors.configurationSource(corsConfigurationSource())) .csrf(csrf -> csrf.disable()) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> { - auth.requestMatchers("/webhook", "/swagger/**", "/swagger-ui/**", "/health").permitAll() + auth.requestMatchers("/auth/**", "/webhook", "/swagger/**", "/swagger-ui/**", "/health", "/error").permitAll() + .requestMatchers(org.springframework.http.HttpMethod.OPTIONS, "/**").permitAll() .anyRequest().authenticated(); }) - .oauth2ResourceServer(oauth2 -> oauth2.jwt(jwt -> - jwt.jwtAuthenticationConverter(new CustomJwtAuthenticationConverter()) - )); + .authenticationProvider(authenticationProvider()) + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } @@ -42,9 +80,10 @@ public class SecurityConfig { CorsConfiguration configuration = new CorsConfiguration(); configuration.setAllowedOrigins(List.of(this.frontendHost)); configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")); - configuration.setAllowedHeaders(Arrays.asList("authorization", "content-type", "x-auth-token", "Access-Control-Allow-Origin")); - configuration.setExposedHeaders(List.of("x-auth-token")); + configuration.setAllowedHeaders(Arrays.asList("Authorization", "Content-Type", "Accept", "Origin", "X-Requested-With", "Access-Control-Request-Method", "Access-Control-Request-Headers", "x-auth-token")); + configuration.setExposedHeaders(Arrays.asList("Authorization", "Content-Type", "x-auth-token", "Access-Control-Allow-Origin", "Access-Control-Allow-Methods", "Access-Control-Allow-Headers")); configuration.setAllowCredentials(true); + configuration.setMaxAge(3600L); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", configuration); return source; diff --git a/backend/src/main/java/de/szut/casino/security/dto/AuthResponseDto.java b/backend/src/main/java/de/szut/casino/security/dto/AuthResponseDto.java new file mode 100644 index 0000000..0dd03dd --- /dev/null +++ b/backend/src/main/java/de/szut/casino/security/dto/AuthResponseDto.java @@ -0,0 +1,19 @@ +package de.szut.casino.security.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class AuthResponseDto { + private String token; + private String tokenType = "Bearer"; + + public AuthResponseDto(String token) { + this.token = token; + } +} diff --git a/backend/src/main/java/de/szut/casino/security/dto/LoginRequestDto.java b/backend/src/main/java/de/szut/casino/security/dto/LoginRequestDto.java new file mode 100644 index 0000000..ace3508 --- /dev/null +++ b/backend/src/main/java/de/szut/casino/security/dto/LoginRequestDto.java @@ -0,0 +1,19 @@ +package de.szut.casino.security.dto; + +import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class LoginRequestDto { + @NotBlank(message = "Username or email is required") + private String usernameOrEmail; + + @NotBlank(message = "Password is required") + private String password; +} diff --git a/backend/src/main/java/de/szut/casino/security/jwt/JwtAuthenticationFilter.java b/backend/src/main/java/de/szut/casino/security/jwt/JwtAuthenticationFilter.java new file mode 100644 index 0000000..502ba0a --- /dev/null +++ b/backend/src/main/java/de/szut/casino/security/jwt/JwtAuthenticationFilter.java @@ -0,0 +1,64 @@ +package de.szut.casino.security.jwt; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@Component +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + @Autowired + private JwtUtils jwtUtils; + + @Autowired + private UserDetailsService userDetailsService; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + try { + String jwt = parseJwt(request); + if (jwt != null) { + String username = jwtUtils.extractUsername(jwt); + + if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) { + UserDetails userDetails = userDetailsService.loadUserByUsername(username); + + if (jwtUtils.validateToken(jwt, userDetails)) { + UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken( + userDetails, null, userDetails.getAuthorities()); + + authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + SecurityContextHolder.getContext().setAuthentication(authToken); + } + } + } + } catch (Exception e) { + logger.error("Cannot set user authentication: {}", e); + } + + filterChain.doFilter(request, response); + } + + private String parseJwt(HttpServletRequest request) { + String headerAuth = request.getHeader("Authorization"); + + if (StringUtils.hasText(headerAuth) && headerAuth.startsWith("Bearer ")) { + return headerAuth.substring(7); + } + + return null; + } +} diff --git a/backend/src/main/java/de/szut/casino/security/jwt/JwtUtils.java b/backend/src/main/java/de/szut/casino/security/jwt/JwtUtils.java new file mode 100644 index 0000000..3c5ef86 --- /dev/null +++ b/backend/src/main/java/de/szut/casino/security/jwt/JwtUtils.java @@ -0,0 +1,83 @@ +package de.szut.casino.security.jwt; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.security.Keys; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Component; + +import java.security.Key; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; +import java.util.function.Function; + +@Component +public class JwtUtils { + + @Value("${jwt.secret}") + private String jwtSecret; + + @Value("${jwt.expiration.ms}") + private int jwtExpirationMs; + + private Key getSigningKey() { + return Keys.hmacShaKeyFor(jwtSecret.getBytes()); + } + + public String generateToken(Authentication authentication) { + UserDetails userDetails = (UserDetails) authentication.getPrincipal(); + return generateToken(userDetails.getUsername()); + } + + public String generateToken(String username) { + Map claims = new HashMap<>(); + return createToken(claims, username); + } + + private String createToken(Map claims, String subject) { + Date now = new Date(); + Date expiryDate = new Date(now.getTime() + jwtExpirationMs); + + return Jwts.builder() + .setClaims(claims) + .setSubject(subject) + .setIssuedAt(now) + .setExpiration(expiryDate) + .signWith(getSigningKey(), SignatureAlgorithm.HS256) + .compact(); + } + + public String extractUsername(String token) { + return extractClaim(token, Claims::getSubject); + } + + public Date extractExpiration(String token) { + return extractClaim(token, Claims::getExpiration); + } + + public T extractClaim(String token, Function claimsResolver) { + final Claims claims = extractAllClaims(token); + return claimsResolver.apply(claims); + } + + private Claims extractAllClaims(String token) { + return Jwts.parserBuilder() + .setSigningKey(getSigningKey()) + .build() + .parseClaimsJws(token) + .getBody(); + } + + private Boolean isTokenExpired(String token) { + return extractExpiration(token).before(new Date()); + } + + public Boolean validateToken(String token, UserDetails userDetails) { + final String username = extractUsername(token); + return (username.equals(userDetails.getUsername()) && !isTokenExpired(token)); + } +} 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 new file mode 100644 index 0000000..88687ba --- /dev/null +++ b/backend/src/main/java/de/szut/casino/security/service/AuthService.java @@ -0,0 +1,50 @@ +package de.szut.casino.security.service; + +import de.szut.casino.security.dto.AuthResponseDto; +import de.szut.casino.security.dto.LoginRequestDto; +import de.szut.casino.security.jwt.JwtUtils; +import de.szut.casino.user.UserEntity; +import de.szut.casino.user.UserService; +import de.szut.casino.user.dto.CreateUserDto; +import de.szut.casino.user.dto.GetUserDto; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Service; + +@Service +public class AuthService { + + @Autowired + private AuthenticationManager authenticationManager; + + @Autowired + private JwtUtils jwtUtils; + + @Autowired + private UserService userService; + + public AuthResponseDto login(LoginRequestDto loginRequest) { + Authentication authentication = authenticationManager.authenticate( + new UsernamePasswordAuthenticationToken( + loginRequest.getUsernameOrEmail(), + loginRequest.getPassword())); + + SecurityContextHolder.getContext().setAuthentication(authentication); + String jwt = jwtUtils.generateToken(authentication); + + return new AuthResponseDto(jwt); + } + + public GetUserDto register(CreateUserDto signUpRequest) { + UserEntity user = userService.createUser(signUpRequest); + return new GetUserDto( + user.getId(), + user.getEmail(), + user.getUsername(), + user.getBalance() + ); + } +} diff --git a/backend/src/main/java/de/szut/casino/security/service/UserDetailsServiceImpl.java b/backend/src/main/java/de/szut/casino/security/service/UserDetailsServiceImpl.java new file mode 100644 index 0000000..b473a35 --- /dev/null +++ b/backend/src/main/java/de/szut/casino/security/service/UserDetailsServiceImpl.java @@ -0,0 +1,36 @@ +package de.szut.casino.security.service; + +import de.szut.casino.user.UserEntity; +import de.szut.casino.user.UserRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.Optional; + +@Service +public class UserDetailsServiceImpl implements UserDetailsService { + + @Autowired + private UserRepository userRepository; + + @Override + public UserDetails loadUserByUsername(String usernameOrEmail) throws UsernameNotFoundException { + Optional user = userRepository.findByUsername(usernameOrEmail); + + if (user.isEmpty()) { + user = userRepository.findByEmail(usernameOrEmail); + } + + UserEntity userEntity = user.orElseThrow(() -> + new UsernameNotFoundException("User not found with username or email: " + usernameOrEmail)); + + return new org.springframework.security.core.userdetails.User( + userEntity.getUsername(), + userEntity.getPassword(), + new ArrayList<>()); + } +} diff --git a/backend/src/main/java/de/szut/casino/slots/SlotController.java b/backend/src/main/java/de/szut/casino/slots/SlotController.java index 5e27b52..b55e2b7 100644 --- a/backend/src/main/java/de/szut/casino/slots/SlotController.java +++ b/backend/src/main/java/de/szut/casino/slots/SlotController.java @@ -8,10 +8,12 @@ import de.szut.casino.user.UserEntity; import de.szut.casino.user.UserService; import jakarta.validation.Valid; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; import java.math.BigDecimal; -import java.util.Collections; import java.util.HashMap; import java.util.Map; import java.util.Optional; @@ -29,8 +31,8 @@ public class SlotController { } @PostMapping("/slots/spin") - public ResponseEntity spinSlots(@RequestBody @Valid BetDto betDto, @RequestHeader("Authorization") String token) { - Optional optionalUser = userService.getCurrentUser(token); + public ResponseEntity spinSlots(@RequestBody @Valid BetDto betDto) { + Optional optionalUser = userService.getCurrentUser(); if (optionalUser.isEmpty()) { throw new UserNotFoundException(); diff --git a/backend/src/main/java/de/szut/casino/user/UserController.java b/backend/src/main/java/de/szut/casino/user/UserController.java index a5cae8c..6255a03 100644 --- a/backend/src/main/java/de/szut/casino/user/UserController.java +++ b/backend/src/main/java/de/szut/casino/user/UserController.java @@ -1,43 +1,28 @@ package de.szut.casino.user; -import de.szut.casino.exceptionHandling.exceptions.UserNotFoundException; -import de.szut.casino.user.dto.CreateUserDto; import de.szut.casino.user.dto.GetUserDto; -import jakarta.validation.Valid; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; +import org.springframework.web.bind.annotation.CrossOrigin; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; @Slf4j @RestController +@CrossOrigin +@RequestMapping("/users") public class UserController { @Autowired private UserService userService; - @PostMapping("/user") - public ResponseEntity createUser(@RequestBody @Valid CreateUserDto userData) { - if (userService.exists(userData.getAuthentikId())) { - HttpHeaders headers = new HttpHeaders(); - headers.add("Location", "/user"); + @Autowired + private UserMappingService userMappingService; - return new ResponseEntity<>(headers, HttpStatus.FOUND); - } - - return ResponseEntity.ok(userService.createUser(userData)); - } - - @GetMapping("/user") - public ResponseEntity getCurrentUser(@RequestHeader("Authorization") String token) { - GetUserDto userData = userService.getCurrentUserAsDto(token); - - if (userData == null) { - throw new UserNotFoundException(); - } - - return ResponseEntity.ok(userData); + @GetMapping("/me") + public ResponseEntity getCurrentUser() { + return ResponseEntity.ok(userMappingService.mapToGetUserDto(userService.getCurrentUser().orElseThrow())); } } 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 03f4a34..913e69d 100644 --- a/backend/src/main/java/de/szut/casino/user/UserEntity.java +++ b/backend/src/main/java/de/szut/casino/user/UserEntity.java @@ -18,16 +18,22 @@ public class UserEntity { @Id @GeneratedValue private Long id; + + @Column(unique = true) + private String email; + @Column(unique = true) - private String authentikId; private String username; - + + private String password; + @Column(precision = 19, scale = 2) private BigDecimal balance; - public UserEntity(String authentikId, String username, BigDecimal balance) { - this.authentikId = authentikId; + public UserEntity(String email, String username, String password, BigDecimal balance) { + this.email = email; this.username = username; + this.password = password; this.balance = balance; } diff --git a/backend/src/main/java/de/szut/casino/user/UserMappingService.java b/backend/src/main/java/de/szut/casino/user/UserMappingService.java index 86a1331..e244646 100644 --- a/backend/src/main/java/de/szut/casino/user/UserMappingService.java +++ b/backend/src/main/java/de/szut/casino/user/UserMappingService.java @@ -1,19 +1,13 @@ package de.szut.casino.user; -import de.szut.casino.user.dto.CreateUserDto; import de.szut.casino.user.dto.GetUserDto; import org.springframework.stereotype.Service; -import java.math.BigDecimal; - @Service public class UserMappingService { + public GetUserDto mapToGetUserDto(UserEntity user) { - return new GetUserDto(user.getAuthentikId(), user.getUsername(), user.getBalance()); - } - - public UserEntity mapToUserEntity(CreateUserDto createUserDto) { - return new UserEntity(createUserDto.getAuthentikId(), createUserDto.getUsername(), BigDecimal.ZERO); + return new GetUserDto(user.getId(), user.getEmail(), user.getUsername(), user.getBalance()); } } 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 1f8d64e..863e744 100644 --- a/backend/src/main/java/de/szut/casino/user/UserRepository.java +++ b/backend/src/main/java/de/szut/casino/user/UserRepository.java @@ -1,15 +1,17 @@ package de.szut.casino.user; import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; import org.springframework.stereotype.Service; import java.util.Optional; @Service public interface UserRepository extends JpaRepository { - @Query("SELECT u FROM UserEntity u WHERE u.authentikId = ?1") - Optional findOneByAuthentikId(String authentikId); - - boolean existsByAuthentikId(String authentikId); + Optional findByUsername(String username); + + Optional findByEmail(String email); + + boolean existsByUsername(String username); + + boolean existsByEmail(String email); } diff --git a/backend/src/main/java/de/szut/casino/user/UserService.java b/backend/src/main/java/de/szut/casino/user/UserService.java index d5ce222..66f2d52 100644 --- a/backend/src/main/java/de/szut/casino/user/UserService.java +++ b/backend/src/main/java/de/szut/casino/user/UserService.java @@ -1,16 +1,12 @@ package de.szut.casino.user; import de.szut.casino.user.dto.CreateUserDto; -import de.szut.casino.user.dto.GetUserDto; -import de.szut.casino.user.dto.KeycloakUserDto; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.http.HttpEntity; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpMethod; -import org.springframework.http.ResponseEntity; +import org.springframework.security.core.context.SecurityContextHolder; +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.Optional; @Service @@ -19,63 +15,30 @@ public class UserService { private UserRepository userRepository; @Autowired - private RestTemplate http; - - @Autowired - private UserMappingService mappingService; + private PasswordEncoder passwordEncoder; public UserEntity createUser(CreateUserDto createUserDto) { - UserEntity user = mappingService.mapToUserEntity(createUserDto); - userRepository.save(user); - - return user; - } - - public GetUserDto getUser(String authentikId) { - Optional user = this.userRepository.findOneByAuthentikId(authentikId); - - return user.map(userEntity -> mappingService.mapToGetUserDto(userEntity)).orElse(null); - } - - public GetUserDto getCurrentUserAsDto(String token) { - KeycloakUserDto userData = getAuthentikUserInfo(token); - - if (userData == null) { - return null; + if (userRepository.existsByUsername(createUserDto.getUsername())) { + throw new IllegalArgumentException("Username is already taken"); } - Optional user = this.userRepository.findOneByAuthentikId(userData.getSub()); - return user.map(userEntity -> mappingService.mapToGetUserDto(userEntity)).orElse(null); - } - - public Optional getCurrentUser(String token) { - KeycloakUserDto userData = getAuthentikUserInfo(token); - - if (userData == null) { - return Optional.empty(); + if (userRepository.existsByEmail(createUserDto.getEmail())) { + throw new IllegalArgumentException("Email is already in use"); } - return this.userRepository.findOneByAuthentikId(userData.getSub()); + + UserEntity user = new UserEntity( + createUserDto.getEmail(), + createUserDto.getUsername(), + passwordEncoder.encode(createUserDto.getPassword()), + BigDecimal.valueOf(10) // Starting balance + ); + + return userRepository.save(user); } - private KeycloakUserDto getAuthentikUserInfo(String token) { - try { - HttpHeaders headers = new HttpHeaders(); - headers.set("Authorization", token); - ResponseEntity response = this.http.exchange( - "https://oauth.simonis.lol/application/o/userinfo/", - HttpMethod.GET, - new HttpEntity<>(headers), - KeycloakUserDto.class - ); - - return response.getBody(); - } catch (Exception e) { - System.err.println("Error fetching user info from Authentik: " + e.getMessage()); - return null; - } - } + public Optional getCurrentUser() { + String username = SecurityContextHolder.getContext().getAuthentication().getName(); - public boolean exists(String authentikId) { - return userRepository.existsByAuthentikId(authentikId); + return userRepository.findByUsername(username); } } diff --git a/backend/src/main/java/de/szut/casino/user/dto/CreateUserDto.java b/backend/src/main/java/de/szut/casino/user/dto/CreateUserDto.java index f983b2d..f9969cc 100644 --- a/backend/src/main/java/de/szut/casino/user/dto/CreateUserDto.java +++ b/backend/src/main/java/de/szut/casino/user/dto/CreateUserDto.java @@ -1,5 +1,8 @@ package de.szut.casino.user.dto; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; @@ -10,6 +13,15 @@ import lombok.Setter; @AllArgsConstructor @NoArgsConstructor public class CreateUserDto { - private String authentikId; + @NotBlank(message = "Email is required") + @Email(message = "Email should be valid") + private String email; + + @NotBlank(message = "Username is required") + @Size(min = 3, max = 20, message = "Username must be between 3 and 20 characters") private String username; + + @NotBlank(message = "Password is required") + @Size(min = 6, message = "Password must be at least 6 characters") + private String password; } diff --git a/backend/src/main/java/de/szut/casino/user/dto/GetUserDto.java b/backend/src/main/java/de/szut/casino/user/dto/GetUserDto.java index 7a7d561..2c41f0d 100644 --- a/backend/src/main/java/de/szut/casino/user/dto/GetUserDto.java +++ b/backend/src/main/java/de/szut/casino/user/dto/GetUserDto.java @@ -12,7 +12,8 @@ import java.math.BigDecimal; @AllArgsConstructor @NoArgsConstructor public class GetUserDto { - private String authentikId; + private Long id; + private String email; private String username; private BigDecimal balance; } diff --git a/backend/src/main/java/de/szut/casino/user/dto/KeycloakUserDto.java b/backend/src/main/java/de/szut/casino/user/dto/KeycloakUserDto.java deleted file mode 100644 index 4238e13..0000000 --- a/backend/src/main/java/de/szut/casino/user/dto/KeycloakUserDto.java +++ /dev/null @@ -1,15 +0,0 @@ -package de.szut.casino.user.dto; - -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.Setter; - -@Getter -@Setter -@AllArgsConstructor -@NoArgsConstructor -public class KeycloakUserDto { - private String sub; - private String preferred_username; -} diff --git a/backend/src/main/java/de/szut/casino/user/transaction/GetTransactionService.java b/backend/src/main/java/de/szut/casino/user/transaction/GetTransactionService.java index 831c438..2a8350f 100644 --- a/backend/src/main/java/de/szut/casino/user/transaction/GetTransactionService.java +++ b/backend/src/main/java/de/szut/casino/user/transaction/GetTransactionService.java @@ -22,7 +22,7 @@ public class GetTransactionService { private TransactionRepository transactionRepository; public UserTransactionsDto getUserTransactionsDto(String authToken, Integer limit, Integer offset) { - Optional user = this.userService.getCurrentUser(authToken); + Optional user = this.userService.getCurrentUser(); if (user.isPresent()) { List transactionEntities = this.transactionRepository.findByUserIdWithLimit(user.get(), limit, offset); Boolean hasMore = this.transactionRepository.hasMore(user.get(), limit, offset); diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index bb8a4be..0d18cfd 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -8,33 +8,15 @@ stripe.webhook.secret=${STRIPE_WEBHOOK_SECRET:whsec_746b6a488665f6057118bdb4a2b3 app.frontend-host=${FE_URL:http://localhost:4200} spring.application.name=casino -#client registration configuration -spring.security.oauth2.client.registration.authentik.client-id=${AUTH_CLIENT_ID:MDqjm1kcWKuZfqHJXjxwAV20i44aT7m4VhhTL3Nm} -spring.security.oauth2.client.registration.authentik.client-secret=${AUTH_CLIENT_SECRET:GY2F8te6iAVYt1TNAUVLzWZEXb6JoMNp6chbjqaXNq4gS5xTDL54HqBiAlV1jFKarN28LQ7FUsYX4SbwjfEhZhgeoKuBnZKjR9eiu7RawnGgxIK9ffvUfMkjRxnmiGI5} -spring.security.oauth2.client.registration.authentik.provider=authentik -spring.security.oauth2.client.registration.authentik.client-name=Authentik -spring.security.oauth2.client.registration.authentik.scope=openid,email,profile -spring.security.oauth2.client.registration.authentik.client-authentication-method=client_secret_basic -spring.security.oauth2.client.registration.authentik.authorization-grant-type=authorization_code -spring.security.oauth2.client.registration.authentik.redirect-uri={baseUrl}/login/oauth2/code/{registrationId} +# JWT Configuration +jwt.secret=${JWT_SECRET:5367566B59703373367639792F423F4528482B4D6251655468576D5A71347437} +jwt.expiration.ms=${JWT_EXPIRATION_MS:86400000} -# Provider settings -spring.security.oauth2.client.provider.authentik.issuer-uri=${AUTH_PROVIDER_ISSUER:https://oauth.simonis.lol/application/o/casino-dev/} -spring.security.oauth2.client.provider.authentik.authorization-uri=${AUTH_PROVIDER_AUTHORIZE_URI:https://oauth.simonis.lol/application/o/authorize/} -spring.security.oauth2.client.provider.authentik.token-uri=${AUTH_PROVIDER_TOKEN_URI:https://oauth.simonis.lol/application/o/token/} -spring.security.oauth2.client.provider.authentik.user-info-uri=${AUTH_PROVIDER_USERINFO_URI:https://oauth.simonis.lol/application/o/userinfo/} -spring.security.oauth2.client.provider.authentik.jwk-set-uri=${AUTH_PROVIDER_JWKS_URI:https://oauth.simonis.lol/application/o/casino-dev/jwks/} -spring.security.oauth2.client.provider.authentik.user-name-attribute=${AUTH_PROVIDER_NAME_ATTR:preferred_username} - -# Resource server config -spring.security.oauth2.resourceserver.jwt.issuer-uri=${AUTH_JWT_ISSUER_URI:https://oauth.simonis.lol/application/o/casino-dev}/ -spring.security.oauth2.resourceserver.jwt.jwk-set-uri=${AUTH_JWT_JWT_SET_URI:https://oauth.simonis.lol/application/o/casino-dev/jwks/} - -#OIDC provider configuration: +# Logging logging.level.org.springframework.security=DEBUG -#validating JWT token against our Authentik server +# Swagger springdoc.swagger-ui.path=swagger springdoc.swagger-ui.try-it-out-enabled=true diff --git a/frontend/bun.lock b/frontend/bun.lock index aaaffeb..70cba54 100644 --- a/frontend/bun.lock +++ b/frontend/bun.lock @@ -21,11 +21,8 @@ "@tailwindcss/postcss": "^4.0.3", "ajv": "8.17.1", "ajv-formats": "3.0.1", - "angular-oauth2-oidc": "^19.0.0", "countup.js": "^2.8.0", "gsap": "^3.12.7", - "keycloak-angular": "^19.0.0", - "keycloak-js": "^26.0.0", "postcss": "^8.5.1", "rxjs": "~7.8.2", "tailwindcss": "^4.0.3", @@ -757,8 +754,6 @@ "angular-eslint": ["angular-eslint@19.3.0", "", { "dependencies": { "@angular-devkit/core": ">= 19.0.0 < 20.0.0", "@angular-devkit/schematics": ">= 19.0.0 < 20.0.0", "@angular-eslint/builder": "19.3.0", "@angular-eslint/eslint-plugin": "19.3.0", "@angular-eslint/eslint-plugin-template": "19.3.0", "@angular-eslint/schematics": "19.3.0", "@angular-eslint/template-parser": "19.3.0", "@typescript-eslint/types": "^8.0.0", "@typescript-eslint/utils": "^8.0.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": "*", "typescript-eslint": "^8.0.0" } }, "sha512-19hkkH3z/2wGhKk3LfttEBkl6CtQP/tFK6/mJoO/MbIkXV0SSJWtbPbOpEaxICLlfCw0oR6W9OoQqByWkwXjkQ=="], - "angular-oauth2-oidc": ["angular-oauth2-oidc@19.0.0", "", { "dependencies": { "tslib": "^2.5.2" }, "peerDependencies": { "@angular/common": ">=19.0.0", "@angular/core": ">=19.0.0" } }, "sha512-EogHyF7MpCJSjSKIyVmdB8pJu7dU5Ilj9VNVSnFbLng4F77PIlaE4egwKUlUvk0i4ZvmO9rLXNQCm05R7Tyhcw=="], - "ansi-colors": ["ansi-colors@4.1.3", "", {}, "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw=="], "ansi-escapes": ["ansi-escapes@4.3.2", "", { "dependencies": { "type-fest": "^0.21.3" } }, "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ=="], @@ -1275,10 +1270,6 @@ "karma-source-map-support": ["karma-source-map-support@1.4.0", "", { "dependencies": { "source-map-support": "^0.5.5" } }, "sha512-RsBECncGO17KAoJCYXjv+ckIz+Ii9NCi+9enk+rq6XC81ezYkb4/RHE6CTXdA7IOJqoF3wcaLfVG0CPmE5ca6A=="], - "keycloak-angular": ["keycloak-angular@19.0.2", "", { "dependencies": { "tslib": "^2.3.1" }, "peerDependencies": { "@angular/common": "^19", "@angular/core": "^19", "@angular/router": "^19", "keycloak-js": "^18 || ^19 || ^20 || ^21 || ^22 || ^23 || ^24 || ^25 || ^26" } }, "sha512-GzQKC/jFJLZRmUxWOEXkla+6shDAZFAOe6Z3qsw916Ckb/UhZnO704HMZrd8xyVB3RH6xOcNCp45oHmIiqJ7dA=="], - - "keycloak-js": ["keycloak-js@26.1.4", "", {}, "sha512-4h2RicCzIAtsjKIG8DIO+8NKlpWX2fiNkbS0jlbtjZFbIGGjbQBzjS/5NkyWlzxamXVow9prHTIgIiwfo3GAmQ=="], - "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], "kind-of": ["kind-of@6.0.3", "", {}, "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw=="], diff --git a/frontend/package.json b/frontend/package.json index 16ced84..ab8328e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -33,9 +33,6 @@ "ajv-formats": "3.0.1", "countup.js": "^2.8.0", "gsap": "^3.12.7", - "angular-oauth2-oidc": "^19.0.0", - "keycloak-angular": "^19.0.0", - "keycloak-js": "^26.0.0", "postcss": "^8.5.1", "rxjs": "~7.8.2", "tailwindcss": "^4.0.3", diff --git a/frontend/src/app/app.component.ts b/frontend/src/app/app.component.ts index 0d82bed..30666e9 100644 --- a/frontend/src/app/app.component.ts +++ b/frontend/src/app/app.component.ts @@ -1,7 +1,7 @@ import { ChangeDetectionStrategy, Component } from '@angular/core'; import { CommonModule } from '@angular/common'; import { RouterOutlet } from '@angular/router'; -import { FooterComponent } from './shared/components/footer/footer.component'; +import { FooterComponent } from '@shared/components/footer/footer.component'; @Component({ selector: 'app-root', diff --git a/frontend/src/app/app.config.ts b/frontend/src/app/app.config.ts index d6ac35a..679c5e4 100644 --- a/frontend/src/app/app.config.ts +++ b/frontend/src/app/app.config.ts @@ -5,7 +5,6 @@ import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; import { routes } from './app.routes'; import { provideHttpClient, withInterceptors } from '@angular/common/http'; import { provideAnimationsAsync } from '@angular/platform-browser/animations/async'; -import { OAuthStorage, provideOAuthClient } from 'angular-oauth2-oidc'; import { httpInterceptor } from '@shared/interceptor/http.interceptor'; export const appConfig: ApplicationConfig = { @@ -15,10 +14,5 @@ export const appConfig: ApplicationConfig = { provideHttpClient(withInterceptors([httpInterceptor])), provideExperimentalZonelessChangeDetection(), provideAnimationsAsync(), - provideOAuthClient(), - { - provide: OAuthStorage, - useFactory: () => localStorage, - }, ], }; diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts index a207b91..7958ffb 100644 --- a/frontend/src/app/app.routes.ts +++ b/frontend/src/app/app.routes.ts @@ -8,8 +8,14 @@ export const routes: Routes = [ component: LandingComponent, }, { - path: 'auth/callback', - loadComponent: () => import('./feature/login-success/login-success.component'), + path: 'login', + loadComponent: () => + import('./feature/auth/login/login.component').then((m) => m.LoginComponent), + }, + { + path: 'register', + loadComponent: () => + import('./feature/auth/register/register.component').then((m) => m.RegisterComponent), }, { path: 'home', @@ -24,7 +30,6 @@ export const routes: Routes = [ { path: 'game/slots', loadComponent: () => import('./feature/game/slots/slots.component'), - canActivate: [authGuard], }, { path: 'game/lootboxes', diff --git a/frontend/src/app/feature/auth/login/login.component.html b/frontend/src/app/feature/auth/login/login.component.html new file mode 100644 index 0000000..c41002d --- /dev/null +++ b/frontend/src/app/feature/auth/login/login.component.html @@ -0,0 +1,77 @@ + + +
+ +
diff --git a/frontend/src/app/feature/auth/login/login.component.ts b/frontend/src/app/feature/auth/login/login.component.ts new file mode 100644 index 0000000..c4cfc4c --- /dev/null +++ b/frontend/src/app/feature/auth/login/login.component.ts @@ -0,0 +1,58 @@ +import { Component } from '@angular/core'; +import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; +import { Router, RouterLink } from '@angular/router'; +import { LoginRequest } from '../../../model/auth/LoginRequest'; +import { AuthService } from '../../../service/auth.service'; +import { CommonModule } from '@angular/common'; +import { NavbarComponent } from '@shared/components/navbar/navbar.component'; + +@Component({ + selector: 'app-login', + standalone: true, + imports: [CommonModule, ReactiveFormsModule, RouterLink, NavbarComponent], + templateUrl: './login.component.html', +}) +export class LoginComponent { + loginForm: FormGroup; + errorMessage = ''; + isLoading = false; + + constructor( + private fb: FormBuilder, + private authService: AuthService, + private router: Router + ) { + this.loginForm = this.fb.group({ + usernameOrEmail: ['', [Validators.required]], + password: ['', [Validators.required]], + }); + } + + get form() { + return this.loginForm.controls; + } + + onSubmit(): void { + if (this.loginForm.invalid) { + return; + } + + this.isLoading = true; + this.errorMessage = ''; + + const loginRequest: LoginRequest = { + usernameOrEmail: this.form['usernameOrEmail'].value, + password: this.form['password'].value, + }; + + this.authService.login(loginRequest).subscribe({ + next: () => { + this.router.navigate(['/home']); + }, + error: (err) => { + this.isLoading = false; + this.errorMessage = err.error?.message || 'Failed to login. Please check your credentials.'; + }, + }); + } +} diff --git a/frontend/src/app/feature/auth/register/register.component.html b/frontend/src/app/feature/auth/register/register.component.html new file mode 100644 index 0000000..3dbeeb7 --- /dev/null +++ b/frontend/src/app/feature/auth/register/register.component.html @@ -0,0 +1,102 @@ + + +
+ +
diff --git a/frontend/src/app/feature/auth/register/register.component.ts b/frontend/src/app/feature/auth/register/register.component.ts new file mode 100644 index 0000000..4c60c4f --- /dev/null +++ b/frontend/src/app/feature/auth/register/register.component.ts @@ -0,0 +1,75 @@ +import { Component } from '@angular/core'; +import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; +import { Router, RouterLink } from '@angular/router'; +import { RegisterRequest } from '../../../model/auth/RegisterRequest'; +import { AuthService } from '@service/auth.service'; +import { CommonModule } from '@angular/common'; +import { NavbarComponent } from '@shared/components/navbar/navbar.component'; + +@Component({ + selector: 'app-register', + standalone: true, + imports: [CommonModule, ReactiveFormsModule, RouterLink, NavbarComponent], + templateUrl: './register.component.html', +}) +export class RegisterComponent { + registerForm: FormGroup; + errorMessage = ''; + isLoading = false; + + constructor( + private fb: FormBuilder, + private authService: AuthService, + private router: Router + ) { + this.registerForm = this.fb.group({ + email: ['', [Validators.required, Validators.email]], + username: ['', [Validators.required, Validators.minLength(3)]], + password: ['', [Validators.required, Validators.minLength(6)]], + }); + } + + get form() { + return this.registerForm.controls; + } + + onSubmit(): void { + if (this.registerForm.invalid) { + return; + } + + this.isLoading = true; + this.errorMessage = ''; + + const registerRequest: RegisterRequest = { + email: this.form['email'].value, + username: this.form['username'].value, + password: this.form['password'].value, + }; + + this.authService.register(registerRequest).subscribe({ + next: () => { + // After registration, log in the user + this.authService + .login({ + usernameOrEmail: registerRequest.email, + password: registerRequest.password, + }) + .subscribe({ + next: () => { + this.router.navigate(['/home']); + }, + error: () => { + this.isLoading = false; + this.errorMessage = + 'Registration successful but failed to login automatically. Please log in manually.'; + }, + }); + }, + error: (err) => { + this.isLoading = false; + this.errorMessage = err.error?.message || 'Failed to register. Please try again.'; + }, + }); + } +} diff --git a/frontend/src/app/feature/game/blackjack/blackjack.component.ts b/frontend/src/app/feature/game/blackjack/blackjack.component.ts index a2e760b..2763b55 100644 --- a/frontend/src/app/feature/game/blackjack/blackjack.component.ts +++ b/frontend/src/app/feature/game/blackjack/blackjack.component.ts @@ -51,7 +51,7 @@ export default class BlackjackComponent implements OnInit { debtAmount = signal(0); ngOnInit(): void { - this.userService.currentUser$.subscribe((user) => { + this.userService.getCurrentUser().subscribe((user) => { if (user) { this.balance.set(user.balance); } diff --git a/frontend/src/app/feature/game/slots/slots.component.css b/frontend/src/app/feature/game/slots/slots.component.css new file mode 100644 index 0000000..0df9046 --- /dev/null +++ b/frontend/src/app/feature/game/slots/slots.component.css @@ -0,0 +1,28 @@ +/* Open button styling - Matches lootbox component style */ +.open-btn { + background: linear-gradient(90deg, #4338ca 0%, #8b5cf6 100%); + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3); + transition: all 0.2s ease; +} +.open-btn:hover { + background: linear-gradient(90deg, #4f46e5 0%, #a78bfa 100%); + transform: translateY(-2px); + box-shadow: 0 6px 8px rgba(0, 0, 0, 0.3); +} + +/* Symbol colors */ +.symbol-BAR { + color: var(--color-accent-yellow); +} +.symbol-SEVEN { + color: var(--color-accent-red); +} +.symbol-BELL { + color: var(--color-accent-purple); +} +.symbol-CHERRY { + color: #ec4899; +} +.symbol-LEMON { + color: #a3e635; +} diff --git a/frontend/src/app/feature/game/slots/slots.component.html b/frontend/src/app/feature/game/slots/slots.component.html index b6d9318..7b0a850 100644 --- a/frontend/src/app/feature/game/slots/slots.component.html +++ b/frontend/src/app/feature/game/slots/slots.component.html @@ -1,50 +1,178 @@ -
-

Payouts

- @if (slotInfo(); as info) { - - - @for (item of info | keyvalue; track item.key) { - - - - - } - -
{{ item.key }}{{ item.value }}
- } +
+

Spielautomaten

-
-
- @for (row of slotResult().resultMatrix; track $index) { - @for (cell of row; track $index) { -
{{ cell }}
- } - } +
+ +
+
+ +
+
+

Slot Machine

+
+ + {{ + slotResult().status === 'win' + ? 'Gewonnen!' + : slotResult().status === 'lose' + ? 'Verloren' + : 'Bereit' + }} + +
+
+
+ + +
+
+
+ @for (row of slotResult().resultMatrix; track $index) { + @for (cell of row; track $index) { +
+ {{ + cell + }} +
+ } + } +
+
+ + +
+
+ +{{ slotResult().amount | currency: 'EUR' }} +
+
+ + +
+
+ + +
+ + +
+
+
-
-

- Game result: {{ slotResult().status | uppercase }} -

-

- Amount: {{ slotResult().amount }} -

-
+ +
+
+

Spiel Informationen

+
+
+ Kontostand: + + + +
+
+ Einsatz: + + + +
-
- - -
+
+ + + + +
- +

Auszahlungen:

+ + @if (slotInfo(); as info) { +
    + @for (item of info | keyvalue; track item.key) { +
  • +
    + {{ item.key }} +
    + {{ item.value }}x +
  • + } +
+ } @else { +
+
+
+ } + +
+

Spielregeln:

+
    +
  • • Gewinne mit 3 gleichen Symbolen
  • +
  • • Höhere Symbole = höhere Gewinne
  • +
+
+
+
+
diff --git a/frontend/src/app/feature/game/slots/slots.component.ts b/frontend/src/app/feature/game/slots/slots.component.ts index 19ffb57..f50fad2 100644 --- a/frontend/src/app/feature/game/slots/slots.component.ts +++ b/frontend/src/app/feature/game/slots/slots.component.ts @@ -1,8 +1,18 @@ -import { ChangeDetectionStrategy, Component, inject, OnInit, signal } from '@angular/core'; +import { + ChangeDetectionStrategy, + Component, + inject, + OnInit, + OnDestroy, + signal, +} from '@angular/core'; import { NavbarComponent } from '@shared/components/navbar/navbar.component'; import { HttpClient } from '@angular/common/http'; -import { KeyValuePipe, NgClass, UpperCasePipe } from '@angular/common'; +import { CommonModule, KeyValuePipe, NgClass, CurrencyPipe } from '@angular/common'; import { FormsModule } from '@angular/forms'; +import { UserService } from '@service/user.service'; +import { Subscription } from 'rxjs'; +import { AnimatedNumberComponent } from '@blackjack/components/animated-number/animated-number.component'; interface SlotResult { status: 'win' | 'lose' | 'blank' | 'start'; @@ -13,12 +23,24 @@ interface SlotResult { @Component({ selector: 'app-slots', standalone: true, - imports: [NavbarComponent, KeyValuePipe, UpperCasePipe, NgClass, FormsModule], + imports: [ + CommonModule, + NavbarComponent, + KeyValuePipe, + NgClass, + FormsModule, + CurrencyPipe, + AnimatedNumberComponent, + ], templateUrl: './slots.component.html', + styleUrl: './slots.component.css', changeDetection: ChangeDetectionStrategy.OnPush, }) -export default class SlotsComponent implements OnInit { +export default class SlotsComponent implements OnInit, OnDestroy { private httpClient: HttpClient = inject(HttpClient); + private userService = inject(UserService); + private userSubscription: Subscription | undefined; + slotInfo = signal | null>(null); slotResult = signal({ status: 'start', @@ -29,21 +51,77 @@ export default class SlotsComponent implements OnInit { ['BELL', 'BELL', 'BELL'], ], }); + + balance = signal(0); betAmount = signal(1); + isSpinning = false; ngOnInit(): void { this.httpClient.get>('/backend/slots/info').subscribe((data) => { this.slotInfo.set(data); }); + + this.userSubscription = this.userService.getCurrentUser().subscribe((user) => { + this.balance.set(user?.balance ?? 0); + }); + + this.userService.refreshCurrentUser(); + } + + ngOnDestroy(): void { + if (this.userSubscription) { + this.userSubscription.unsubscribe(); + } + } + + getSymbolClass(symbol: string): string { + return `symbol-${symbol}`; + } + + hasEnoughBalance(): boolean { + return this.balance() >= this.betAmount(); + } + + setBetAmount(percentage: number): void { + const calculatedBet = Math.floor(this.balance() * percentage * 100) / 100; + const minimumBet = 0.01; + + const newBet = Math.max(minimumBet, Math.min(calculatedBet, this.balance())); + + this.betAmount.set(newBet); } spin(): void { + if (!this.hasEnoughBalance()) { + return; + } + + this.isSpinning = true; + const betAmount = this.betAmount(); + + this.userService.updateLocalBalance(-betAmount); + const payload = { - betAmount: this.betAmount(), + betAmount: betAmount, }; - this.httpClient.post('/backend/slots/spin', payload).subscribe((result) => { - this.slotResult.set(result); + this.httpClient.post('/backend/slots/spin', payload).subscribe({ + next: (result) => { + setTimeout(() => { + this.slotResult.set(result); + + if (result.status === 'win') { + this.userService.updateLocalBalance(result.amount); + } + + this.isSpinning = false; + }, 1500); + }, + error: (err) => { + console.error('Error spinning slot machine:', err); + this.userService.updateLocalBalance(betAmount); + this.isSpinning = false; + }, }); } } diff --git a/frontend/src/app/feature/landing/landing.component.html b/frontend/src/app/feature/landing/landing.component.html index bffa9ad..1e5627d 100644 --- a/frontend/src/app/feature/landing/landing.component.html +++ b/frontend/src/app/feature/landing/landing.component.html @@ -10,9 +10,29 @@
200% bis zu 500€

+ 200 Freispiele

- +
+ @if (authService.isLoggedIn()) { + + Spiele + + } @else { + + Konto erstellen + + + Anmelden + + } +
diff --git a/frontend/src/app/feature/landing/landing.component.ts b/frontend/src/app/feature/landing/landing.component.ts index 915547f..a3b77e3 100644 --- a/frontend/src/app/feature/landing/landing.component.ts +++ b/frontend/src/app/feature/landing/landing.component.ts @@ -1,17 +1,20 @@ -import { ChangeDetectionStrategy, Component, OnDestroy, OnInit } from '@angular/core'; +import { ChangeDetectionStrategy, Component, inject, OnDestroy, OnInit } from '@angular/core'; import { NgFor } from '@angular/common'; import { NavbarComponent } from '@shared/components/navbar/navbar.component'; +import { RouterLink } from '@angular/router'; +import { AuthService } from '@service/auth.service'; @Component({ selector: 'app-landing-page', standalone: true, - imports: [NavbarComponent, NgFor], + imports: [NavbarComponent, NgFor, RouterLink], templateUrl: './landing.component.html', changeDetection: ChangeDetectionStrategy.OnPush, }) export class LandingComponent implements OnInit, OnDestroy { currentSlide = 0; private autoplayInterval: ReturnType | undefined; + authService: AuthService = inject(AuthService); ngOnInit() { this.startAutoplay(); diff --git a/frontend/src/app/feature/lootboxes/lootbox-opening/lootbox-opening.component.ts b/frontend/src/app/feature/lootboxes/lootbox-opening/lootbox-opening.component.ts index c1255ef..3d84d9d 100644 --- a/frontend/src/app/feature/lootboxes/lootbox-opening/lootbox-opening.component.ts +++ b/frontend/src/app/feature/lootboxes/lootbox-opening/lootbox-opening.component.ts @@ -1,4 +1,4 @@ -import { Component, ChangeDetectorRef } from '@angular/core'; +import { ChangeDetectorRef, Component } from '@angular/core'; import { CommonModule } from '@angular/common'; import { ActivatedRoute, Router } from '@angular/router'; import { LootboxService } from '../services/lootbox.service'; diff --git a/frontend/src/app/feature/lootboxes/lootbox-selection/lootbox-selection.component.ts b/frontend/src/app/feature/lootboxes/lootbox-selection/lootbox-selection.component.ts index bc3023c..747e29e 100644 --- a/frontend/src/app/feature/lootboxes/lootbox-selection/lootbox-selection.component.ts +++ b/frontend/src/app/feature/lootboxes/lootbox-selection/lootbox-selection.component.ts @@ -1,4 +1,4 @@ -import { Component, OnInit, ChangeDetectorRef } from '@angular/core'; +import { ChangeDetectorRef, Component, OnInit } from '@angular/core'; import { CommonModule } from '@angular/common'; import { NavbarComponent } from '@shared/components/navbar/navbar.component'; import { LootboxService } from '../services/lootbox.service'; diff --git a/frontend/src/app/feature/lootboxes/services/lootbox.service.ts b/frontend/src/app/feature/lootboxes/services/lootbox.service.ts index b0fb58d..1d1661a 100644 --- a/frontend/src/app/feature/lootboxes/services/lootbox.service.ts +++ b/frontend/src/app/feature/lootboxes/services/lootbox.service.ts @@ -1,6 +1,6 @@ -import { Injectable, inject } from '@angular/core'; +import { inject, Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; -import { Observable, catchError } from 'rxjs'; +import { catchError, Observable } from 'rxjs'; import { LootBox, Reward } from 'app/model/LootBox'; @Injectable({ diff --git a/frontend/src/app/feature/transaction-history/transaction-history.component.ts b/frontend/src/app/feature/transaction-history/transaction-history.component.ts index 338ef4e..dabc255 100644 --- a/frontend/src/app/feature/transaction-history/transaction-history.component.ts +++ b/frontend/src/app/feature/transaction-history/transaction-history.component.ts @@ -1,11 +1,4 @@ -import { - ChangeDetectionStrategy, - Component, - EventEmitter, - inject, - Input, - Output, -} from '@angular/core'; +import { ChangeDetectionStrategy, Component, EventEmitter, inject, Input, Output } from '@angular/core'; import { TransactionService } from '@service/transaction.service'; import { Observable } from 'rxjs'; import { AsyncPipe, CurrencyPipe, DatePipe, NgIf } from '@angular/common'; diff --git a/frontend/src/app/model/User.ts b/frontend/src/app/model/User.ts index 03480a9..3c983b8 100644 --- a/frontend/src/app/model/User.ts +++ b/frontend/src/app/model/User.ts @@ -1,5 +1,6 @@ export interface User { - authentikId: string; + id: number; + email: string; username: string; balance: number; } diff --git a/frontend/src/app/model/auth/AuthResponse.ts b/frontend/src/app/model/auth/AuthResponse.ts new file mode 100644 index 0000000..495d0cd --- /dev/null +++ b/frontend/src/app/model/auth/AuthResponse.ts @@ -0,0 +1,4 @@ +export interface AuthResponse { + token: string; + tokenType: string; +} diff --git a/frontend/src/app/model/auth/LoginRequest.ts b/frontend/src/app/model/auth/LoginRequest.ts new file mode 100644 index 0000000..98c1b0e --- /dev/null +++ b/frontend/src/app/model/auth/LoginRequest.ts @@ -0,0 +1,4 @@ +export interface LoginRequest { + usernameOrEmail: string; + password: string; +} diff --git a/frontend/src/app/model/auth/RegisterRequest.ts b/frontend/src/app/model/auth/RegisterRequest.ts new file mode 100644 index 0000000..2b07d41 --- /dev/null +++ b/frontend/src/app/model/auth/RegisterRequest.ts @@ -0,0 +1,5 @@ +export interface RegisterRequest { + email: string; + username: string; + password: string; +} diff --git a/frontend/src/app/service/auth.service.ts b/frontend/src/app/service/auth.service.ts index 7300a25..1066008 100644 --- a/frontend/src/app/service/auth.service.ts +++ b/frontend/src/app/service/auth.service.ts @@ -1,208 +1,94 @@ -import { inject, Injectable } from '@angular/core'; -import { AuthConfig, OAuthEvent, OAuthService } from 'angular-oauth2-oidc'; -import { UserService } from './user.service'; -import { User } from '../model/User'; +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { BehaviorSubject, Observable, tap } from 'rxjs'; import { Router } from '@angular/router'; -import { environment } from '../../environments/environment'; -import { catchError, from, of } from 'rxjs'; +import { LoginRequest } from '../model/auth/LoginRequest'; +import { RegisterRequest } from '../model/auth/RegisterRequest'; +import { AuthResponse } from '../model/auth/AuthResponse'; +import { User } from '../model/User'; +import { environment } from '@environments/environment'; + +const TOKEN_KEY = 'token'; +const USER_KEY = 'user'; @Injectable({ providedIn: 'root', }) export class AuthService { - private readonly authConfig: AuthConfig = { - issuer: 'https://oauth.simonis.lol/application/o/casino-dev/', - clientId: environment.OAUTH_CLIENT_ID, - dummyClientSecret: environment.OAUTH_CLIENT_SECRET, - scope: `openid email profile ${environment.OAUTH_CLIENT_ID}`, - responseType: 'code', - redirectUri: window.location.origin + '/auth/callback', - postLogoutRedirectUri: '', - redirectUriAsPostLogoutRedirectUriFallback: false, - oidc: true, - requestAccessToken: true, - tokenEndpoint: 'https://oauth.simonis.lol/application/o/token/', - userinfoEndpoint: 'https://oauth.simonis.lol/application/o/userinfo/', - strictDiscoveryDocumentValidation: false, - skipIssuerCheck: true, - disableAtHashCheck: true, - requireHttps: false, - showDebugInformation: false, - sessionChecksEnabled: false, - }; + private authUrl = `${environment.apiUrl}/auth`; + private userUrl = `${environment.apiUrl}/users`; - private userService: UserService = inject(UserService); - private oauthService: OAuthService = inject(OAuthService); - private router: Router = inject(Router); + userSubject: BehaviorSubject; - private user: User | null = null; + constructor( + private http: HttpClient, + private router: Router + ) { + this.userSubject = new BehaviorSubject(this.getUserFromStorage()); - constructor() { - this.oauthService.configure(this.authConfig); - this.setupEventHandling(); - - const hasAuthParams = - window.location.search.includes('code=') || - window.location.search.includes('token=') || - window.location.search.includes('id_token='); - - if (hasAuthParams) { - this.processCodeFlow(); - } else { - this.checkExistingSession(); + if (this.getToken()) { + this.loadCurrentUser(); } } - private processCodeFlow() { - this.oauthService - .tryLogin({ - onTokenReceived: () => { - this.handleSuccessfulLogin(); - }, + public get currentUserValue(): User | null { + return this.userSubject.value; + } + + login(loginRequest: LoginRequest): Observable { + return this.http.post(`${this.authUrl}/login`, loginRequest).pipe( + tap((response) => { + this.setToken(response.token); + this.loadCurrentUser(); }) - .catch((err) => { - console.error('Error processing code flow:', err); - }); + ); } - private checkExistingSession() { - this.oauthService - .loadDiscoveryDocumentAndTryLogin() - .then((isLoggedIn) => { - if (isLoggedIn && !this.user) { - this.handleSuccessfulLogin(); - } - }) - .catch((err) => { - console.error('Error during initial login attempt:', err); - }); + register(registerRequest: RegisterRequest): Observable { + return this.http.post(`${this.authUrl}/register`, registerRequest); } - private setupEventHandling() { - this.oauthService.events.subscribe((event: OAuthEvent) => { - if (event.type === 'token_received') { - this.handleSuccessfulLogin(); - } - }); + logout(): void { + localStorage.removeItem(TOKEN_KEY); + localStorage.removeItem(USER_KEY); + this.userSubject.next(null); + this.router.navigate(['/']); } - private handleSuccessfulLogin() { - const claims = this.oauthService.getIdentityClaims(); - - if (claims && (claims['sub'] || claims['email'])) { - this.processUserProfile(claims); - return; - } - - try { - from(this.oauthService.loadUserProfile()) - .pipe( - catchError((error) => { - console.error('Error loading user profile:', error); - if (this.oauthService.hasValidAccessToken()) { - this.oauthService.getAccessToken(); - const minimalProfile = { - sub: 'user-' + Math.random().toString(36).substring(2, 10), - preferred_username: 'user' + Date.now(), - }; - return of({ info: minimalProfile }); - } - return of(null); - }) - ) - .subscribe((profile) => { - if (profile) { - this.processUserProfile(profile); - } else { - this.router.navigate(['/']); - } - }); - } catch (err) { - console.error('Exception in handleSuccessfulLogin:', err); - if (this.oauthService.hasValidAccessToken()) { - this.router.navigate(['/home']); - } else { - this.router.navigate(['/']); - } - } + isLoggedIn(): boolean { + return !!this.getToken(); } - private processUserProfile(profile: unknown) { - this.fromUserProfile(profile as Record).subscribe({ + getToken(): string | null { + return localStorage.getItem(TOKEN_KEY); + } + + public loadCurrentUser(): void { + this.http.get(`${this.userUrl}/me`).subscribe({ next: (user) => { - this.user = user; - this.router.navigate(['home']); + this.setUser(user); }, - error: (err) => { - console.error('Error creating/retrieving user:', err); - if (this.oauthService.hasValidAccessToken()) { - this.router.navigate(['/home']); - } else { - this.router.navigate(['/']); - } + error: () => { + this.logout(); }, }); } - login() { - try { - this.oauthService - .loadDiscoveryDocument() - .then(() => { - this.oauthService.initLoginFlow(); - }) - .catch((err) => { - console.error('Error loading discovery document:', err); - this.oauthService.initLoginFlow(); - }); - } catch (err) { - console.error('Exception in login:', err); - const redirectUri = this.authConfig.redirectUri || window.location.origin + '/auth/callback'; - const scope = this.authConfig.scope || 'openid email profile'; - const authUrl = `${this.authConfig.issuer}authorize?client_id=${this.authConfig.clientId}&redirect_uri=${encodeURIComponent(redirectUri)}&response_type=code&scope=${encodeURIComponent(scope)}`; - window.location.href = authUrl; - } + private setToken(token: string): void { + localStorage.setItem(TOKEN_KEY, token); } - logout() { - try { - this.user = null; - - this.oauthService.logOut(true); - - if (window.location.href.includes('id_token') || window.location.href.includes('logout')) { - window.location.href = window.location.origin; - } - - localStorage.removeItem('access_token'); - localStorage.removeItem('id_token'); - localStorage.removeItem('refresh_token'); - sessionStorage.removeItem('access_token'); - sessionStorage.removeItem('id_token'); - sessionStorage.removeItem('refresh_token'); - - this.router.navigate(['/']); - } catch (err) { - console.error('Exception in logout:', err); - localStorage.clear(); - sessionStorage.clear(); - this.router.navigate(['/']); - } + private setUser(user: User): void { + localStorage.setItem(USER_KEY, JSON.stringify(user)); + this.userSubject.next(user); } - isLoggedIn() { - return this.oauthService.hasValidAccessToken(); + private getUserFromStorage(): User | null { + const user = localStorage.getItem(USER_KEY); + return user ? JSON.parse(user) : null; } - private fromUserProfile(profile: Record) { - return this.userService.getOrCreateUser(profile); - } - - getAccessToken() { - return this.oauthService.getAccessToken(); - } - - getUser() { - return this.user; + getUser(): User | null { + return this.currentUserValue; } } diff --git a/frontend/src/app/service/user.service.ts b/frontend/src/app/service/user.service.ts index a76e7b4..d7199b3 100644 --- a/frontend/src/app/service/user.service.ts +++ b/frontend/src/app/service/user.service.ts @@ -2,28 +2,19 @@ import { inject, Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { BehaviorSubject, catchError, EMPTY, Observable, tap } from 'rxjs'; import { User } from '../model/User'; +import { AuthService } from '@service/auth.service'; @Injectable({ providedIn: 'root', }) export class UserService { - private http: HttpClient = inject(HttpClient); - private currentUserSubject = new BehaviorSubject(null); + public currentUserSubject = new BehaviorSubject(null); public currentUser$ = this.currentUserSubject.asObservable(); - - constructor() { - this.getCurrentUser().subscribe(); - } - - public getUser(id: string): Observable { - return this.http.get(`/backend/user/${id}`).pipe( - catchError(() => EMPTY), - tap((user) => this.currentUserSubject.next(user)) - ); - } + private http: HttpClient = inject(HttpClient); + private authService = inject(AuthService); public getCurrentUser(): Observable { - return this.http.get('/backend/user').pipe( + return this.http.get('/backend/users/me').pipe( catchError(() => EMPTY), tap((user) => this.currentUserSubject.next(user)) ); @@ -31,6 +22,7 @@ export class UserService { public refreshCurrentUser(): void { this.getCurrentUser().subscribe(); + this.authService.loadCurrentUser(); } public updateLocalBalance(amount: number): void { @@ -43,29 +35,4 @@ export class UserService { this.currentUserSubject.next(updatedUser); } } - - public createUser(id: string, username: string): Observable { - return this.http - .post('/backend/user', { - authentikId: id, - username: username, - }) - .pipe(tap((user) => this.currentUserSubject.next(user))); - } - - public getOrCreateUser(profile: Record): Observable { - const info = profile['info'] as Record | undefined; - const id = (info?.['sub'] as string) || (profile['sub'] as string); - const username = - (info?.['preferred_username'] as string) || - (profile['preferred_username'] as string) || - (profile['email'] as string) || - (profile['name'] as string); - - if (!id || !username) { - throw new Error('Invalid user profile data'); - } - - return this.createUser(id, username); - } } diff --git a/frontend/src/app/shared/components/navbar/navbar.component.html b/frontend/src/app/shared/components/navbar/navbar.component.html index e9f8e91..90b63eb 100644 --- a/frontend/src/app/shared/components/navbar/navbar.component.html +++ b/frontend/src/app/shared/components/navbar/navbar.component.html @@ -12,7 +12,12 @@