From 00fe49b1096373662eac37598520f8d84fcd078c Mon Sep 17 00:00:00 2001 From: sBubshait Date: Sun, 20 Jul 2025 15:59:24 +0300 Subject: [PATCH] feat: add users and user login to backend --- backend/pom.xml | 20 ++++ .../wesal/config/JwtAuthenticationFilter.java | 58 +++++++++ .../online/wesal/wesal/config/JwtUtil.java | 64 ++++++++++ .../wesal/wesal/config/OpenApiConfig.java | 28 +++++ .../wesal/wesal/config/SecurityConfig.java | 78 ++++++++++++ .../wesal/controller/AuthController.java | 68 +++++++++++ .../online/wesal/wesal/dto/LoginRequest.java | 35 ++++++ .../online/wesal/wesal/dto/LoginResponse.java | 29 +++++ .../wesal/dto/UsernameSelectionRequest.java | 27 +++++ .../java/online/wesal/wesal/entity/User.java | 111 ++++++++++++++++++ .../wesal/repository/UserRepository.java | 15 +++ .../wesal/wesal/service/AuthService.java | 46 ++++++++ .../wesal/service/UserDetailsServiceImpl.java | 30 +++++ .../wesal/wesal/service/UserService.java | 36 ++++++ 14 files changed, 645 insertions(+) create mode 100644 backend/src/main/java/online/wesal/wesal/config/JwtAuthenticationFilter.java create mode 100644 backend/src/main/java/online/wesal/wesal/config/JwtUtil.java create mode 100644 backend/src/main/java/online/wesal/wesal/config/OpenApiConfig.java create mode 100644 backend/src/main/java/online/wesal/wesal/config/SecurityConfig.java create mode 100644 backend/src/main/java/online/wesal/wesal/controller/AuthController.java create mode 100644 backend/src/main/java/online/wesal/wesal/dto/LoginRequest.java create mode 100644 backend/src/main/java/online/wesal/wesal/dto/LoginResponse.java create mode 100644 backend/src/main/java/online/wesal/wesal/dto/UsernameSelectionRequest.java create mode 100644 backend/src/main/java/online/wesal/wesal/entity/User.java create mode 100644 backend/src/main/java/online/wesal/wesal/repository/UserRepository.java create mode 100644 backend/src/main/java/online/wesal/wesal/service/AuthService.java create mode 100644 backend/src/main/java/online/wesal/wesal/service/UserDetailsServiceImpl.java create mode 100644 backend/src/main/java/online/wesal/wesal/service/UserService.java diff --git a/backend/pom.xml b/backend/pom.xml index c03a991..ba34886 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -68,6 +68,26 @@ spring-security-test test + + io.jsonwebtoken + jjwt-api + 0.12.3 + + + io.jsonwebtoken + jjwt-impl + 0.12.3 + + + io.jsonwebtoken + jjwt-jackson + 0.12.3 + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + 2.3.0 + diff --git a/backend/src/main/java/online/wesal/wesal/config/JwtAuthenticationFilter.java b/backend/src/main/java/online/wesal/wesal/config/JwtAuthenticationFilter.java new file mode 100644 index 0000000..2050e5c --- /dev/null +++ b/backend/src/main/java/online/wesal/wesal/config/JwtAuthenticationFilter.java @@ -0,0 +1,58 @@ +package online.wesal.wesal.config; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import online.wesal.wesal.service.UserDetailsServiceImpl; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@Component +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + @Autowired + private JwtUtil jwtUtil; + + @Autowired + private UserDetailsServiceImpl userDetailsService; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + + final String authorizationHeader = request.getHeader("Authorization"); + + String username = null; + String jwt = null; + + if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) { + jwt = authorizationHeader.substring(7); + try { + username = jwtUtil.extractUsername(jwt); + } catch (Exception e) { + logger.error("JWT Token extraction failed", e); + } + } + + if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) { + UserDetails userDetails = this.userDetailsService.loadUserByUsername(username); + + if (jwtUtil.validateToken(jwt, userDetails.getUsername())) { + UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken( + userDetails, null, userDetails.getAuthorities()); + authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + SecurityContextHolder.getContext().setAuthentication(authToken); + } + } + + filterChain.doFilter(request, response); + } +} \ No newline at end of file diff --git a/backend/src/main/java/online/wesal/wesal/config/JwtUtil.java b/backend/src/main/java/online/wesal/wesal/config/JwtUtil.java new file mode 100644 index 0000000..2aa3f69 --- /dev/null +++ b/backend/src/main/java/online/wesal/wesal/config/JwtUtil.java @@ -0,0 +1,64 @@ +package online.wesal.wesal.config; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.util.Date; +import java.util.function.Function; + +@Component +public class JwtUtil { + + @Value("${jwt.secret:mySecretKey123456789012345678901234567890}") + private String secret; + + @Value("${jwt.expiration:86400000}") + private Long expiration; + + private SecretKey getSigningKey() { + return Keys.hmacShaKeyFor(secret.getBytes()); + } + + public String generateToken(String username) { + return Jwts.builder() + .subject(username) + .issuedAt(new Date()) + .expiration(new Date(System.currentTimeMillis() + expiration)) + .signWith(getSigningKey()) + .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.parser() + .verifyWith(getSigningKey()) + .build() + .parseSignedClaims(token) + .getPayload(); + } + + public Boolean isTokenExpired(String token) { + return extractExpiration(token).before(new Date()); + } + + public Boolean validateToken(String token, String username) { + final String extractedUsername = extractUsername(token); + return (extractedUsername.equals(username) && !isTokenExpired(token)); + } +} \ No newline at end of file diff --git a/backend/src/main/java/online/wesal/wesal/config/OpenApiConfig.java b/backend/src/main/java/online/wesal/wesal/config/OpenApiConfig.java new file mode 100644 index 0000000..5b85f6f --- /dev/null +++ b/backend/src/main/java/online/wesal/wesal/config/OpenApiConfig.java @@ -0,0 +1,28 @@ +package online.wesal.wesal.config; + +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class OpenApiConfig { + + @Bean + public OpenAPI customOpenAPI() { + return new OpenAPI() + .info(new Info() + .title("Wesal API") + .description("Social media application API") + .version("1.0.0")) + .addSecurityItem(new SecurityRequirement().addList("Bearer Authentication")) + .components(new io.swagger.v3.oas.models.Components() + .addSecuritySchemes("Bearer Authentication", + new SecurityScheme() + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT"))); + } +} \ No newline at end of file diff --git a/backend/src/main/java/online/wesal/wesal/config/SecurityConfig.java b/backend/src/main/java/online/wesal/wesal/config/SecurityConfig.java new file mode 100644 index 0000000..e812afb --- /dev/null +++ b/backend/src/main/java/online/wesal/wesal/config/SecurityConfig.java @@ -0,0 +1,78 @@ +package online.wesal.wesal.config; + +import online.wesal.wesal.service.UserDetailsServiceImpl; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +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.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import java.util.Arrays; + +@Configuration +@EnableWebSecurity +public class SecurityConfig { + + @Autowired + private UserDetailsServiceImpl userDetailsService; + + @Autowired + private JwtAuthenticationFilter jwtAuthenticationFilter; + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public DaoAuthenticationProvider authenticationProvider() { + DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider(); + authProvider.setUserDetailsService(userDetailsService); + authProvider.setPasswordEncoder(passwordEncoder()); + return authProvider; + } + + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception { + return config.getAuthenticationManager(); + } + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + configuration.setAllowedOriginPatterns(Arrays.asList("*")); + configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS")); + configuration.setAllowedHeaders(Arrays.asList("*")); + configuration.setAllowCredentials(true); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + return source; + } + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http.cors(cors -> cors.configurationSource(corsConfigurationSource())) + .csrf(csrf -> csrf.disable()) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(auth -> auth + .requestMatchers("/", "/login", "/swagger-ui/**", "/v3/api-docs/**").permitAll() + .anyRequest().authenticated() + ) + .authenticationProvider(authenticationProvider()) + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } +} \ No newline at end of file diff --git a/backend/src/main/java/online/wesal/wesal/controller/AuthController.java b/backend/src/main/java/online/wesal/wesal/controller/AuthController.java new file mode 100644 index 0000000..e7934a8 --- /dev/null +++ b/backend/src/main/java/online/wesal/wesal/controller/AuthController.java @@ -0,0 +1,68 @@ +package online.wesal.wesal.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import online.wesal.wesal.dto.LoginRequest; +import online.wesal.wesal.dto.LoginResponse; +import online.wesal.wesal.dto.UsernameSelectionRequest; +import online.wesal.wesal.entity.User; +import online.wesal.wesal.service.AuthService; +import online.wesal.wesal.service.UserService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +@RestController +@RequestMapping("/") +@CrossOrigin(origins = "*") +@Tag(name = "Authentication", description = "Authentication endpoints") +public class AuthController { + + @Autowired + private AuthService authService; + + @Autowired + private UserService userService; + + @GetMapping("/") + @Operation(summary = "Health check", description = "Returns API status") + public ResponseEntity> healthCheck() { + return ResponseEntity.ok(Map.of("status", 200)); + } + + @PostMapping("/login") + @Operation(summary = "User login", description = "Authenticate user with email or username and return JWT token") + public ResponseEntity login(@Valid @RequestBody LoginRequest loginRequest) { + try { + String token = authService.authenticate(loginRequest.getEmailOrUsername(), loginRequest.getPassword()); + return ResponseEntity.ok(new LoginResponse(token)); + } catch (RuntimeException e) { + return ResponseEntity.badRequest().build(); + } + } + + @PostMapping("/set-username") + @Operation(summary = "Set username", description = "Set username for authenticated user (can only be done once)") + public ResponseEntity setUsername(@Valid @RequestBody UsernameSelectionRequest request) { + try { + User user = userService.setUsername(request.getUsername()); + return ResponseEntity.ok(user); + } catch (RuntimeException e) { + return ResponseEntity.badRequest().build(); + } + } + + @GetMapping("/getUser") + @Operation(summary = "Get current user", description = "Returns the authenticated user's details") + public ResponseEntity getUser() { + try { + User user = userService.getCurrentUser(); + return ResponseEntity.ok(user); + } catch (RuntimeException e) { + return ResponseEntity.badRequest().build(); + } + } +} \ No newline at end of file diff --git a/backend/src/main/java/online/wesal/wesal/dto/LoginRequest.java b/backend/src/main/java/online/wesal/wesal/dto/LoginRequest.java new file mode 100644 index 0000000..be8c25e --- /dev/null +++ b/backend/src/main/java/online/wesal/wesal/dto/LoginRequest.java @@ -0,0 +1,35 @@ +package online.wesal.wesal.dto; + +import jakarta.validation.constraints.NotBlank; + +public class LoginRequest { + + @NotBlank + private String emailOrUsername; + + @NotBlank + private String password; + + public LoginRequest() {} + + public LoginRequest(String emailOrUsername, String password) { + this.emailOrUsername = emailOrUsername; + this.password = password; + } + + public String getEmailOrUsername() { + return emailOrUsername; + } + + public void setEmailOrUsername(String emailOrUsername) { + this.emailOrUsername = emailOrUsername; + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } +} \ No newline at end of file diff --git a/backend/src/main/java/online/wesal/wesal/dto/LoginResponse.java b/backend/src/main/java/online/wesal/wesal/dto/LoginResponse.java new file mode 100644 index 0000000..25c6b82 --- /dev/null +++ b/backend/src/main/java/online/wesal/wesal/dto/LoginResponse.java @@ -0,0 +1,29 @@ +package online.wesal.wesal.dto; + +public class LoginResponse { + + private String token; + private String type = "Bearer"; + + public LoginResponse() {} + + public LoginResponse(String token) { + this.token = token; + } + + public String getToken() { + return token; + } + + public void setToken(String token) { + this.token = token; + } + + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } +} \ No newline at end of file diff --git a/backend/src/main/java/online/wesal/wesal/dto/UsernameSelectionRequest.java b/backend/src/main/java/online/wesal/wesal/dto/UsernameSelectionRequest.java new file mode 100644 index 0000000..549479c --- /dev/null +++ b/backend/src/main/java/online/wesal/wesal/dto/UsernameSelectionRequest.java @@ -0,0 +1,27 @@ +package online.wesal.wesal.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +public class UsernameSelectionRequest { + + @NotBlank + @Size(min = 3, max = 20) + @Pattern(regexp = "^[a-zA-Z0-9_]+$", message = "Username can only contain letters, numbers, and underscores") + private String username; + + public UsernameSelectionRequest() {} + + public UsernameSelectionRequest(String username) { + this.username = username; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } +} \ No newline at end of file diff --git a/backend/src/main/java/online/wesal/wesal/entity/User.java b/backend/src/main/java/online/wesal/wesal/entity/User.java new file mode 100644 index 0000000..61131bb --- /dev/null +++ b/backend/src/main/java/online/wesal/wesal/entity/User.java @@ -0,0 +1,111 @@ +package online.wesal.wesal.entity; + +import jakarta.persistence.*; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +@Entity +@Table(name = "users") +public class User { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(unique = true, nullable = false) + @Email + @NotBlank + private String email; + + @Column(unique = true) + private String username; + + @Column(nullable = false) + @NotBlank + @Size(min = 8) + private String password; + + @Column(nullable = false) + @NotBlank + private String displayName; + + private String avatar; + + private String fcmToken; + + @Column(nullable = false) + private boolean activated = false; + + public User() {} + + public User(String email, String password, String displayName) { + this.email = email; + this.password = password; + this.displayName = displayName; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } + + public String getDisplayName() { + return displayName; + } + + public void setDisplayName(String displayName) { + this.displayName = displayName; + } + + public String getAvatar() { + return avatar; + } + + public void setAvatar(String avatar) { + this.avatar = avatar; + } + + public String getFcmToken() { + return fcmToken; + } + + public void setFcmToken(String fcmToken) { + this.fcmToken = fcmToken; + } + + public boolean isActivated() { + return activated; + } + + public void setActivated(boolean activated) { + this.activated = activated; + } +} \ No newline at end of file diff --git a/backend/src/main/java/online/wesal/wesal/repository/UserRepository.java b/backend/src/main/java/online/wesal/wesal/repository/UserRepository.java new file mode 100644 index 0000000..d9013c0 --- /dev/null +++ b/backend/src/main/java/online/wesal/wesal/repository/UserRepository.java @@ -0,0 +1,15 @@ +package online.wesal.wesal.repository; + +import online.wesal.wesal.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface UserRepository extends JpaRepository { + Optional findByEmail(String email); + Optional findByUsername(String username); + boolean existsByEmail(String email); + boolean existsByUsername(String username); +} \ No newline at end of file diff --git a/backend/src/main/java/online/wesal/wesal/service/AuthService.java b/backend/src/main/java/online/wesal/wesal/service/AuthService.java new file mode 100644 index 0000000..3a0dabc --- /dev/null +++ b/backend/src/main/java/online/wesal/wesal/service/AuthService.java @@ -0,0 +1,46 @@ +package online.wesal.wesal.service; + +import online.wesal.wesal.config.JwtUtil; +import online.wesal.wesal.entity.User; +import online.wesal.wesal.repository.UserRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; + +import java.util.Optional; + +@Service +public class AuthService { + + @Autowired + private UserRepository userRepository; + + @Autowired + private PasswordEncoder passwordEncoder; + + @Autowired + private JwtUtil jwtUtil; + + public String authenticate(String emailOrUsername, String password) { + Optional userOpt = userRepository.findByEmail(emailOrUsername); + + if (userOpt.isEmpty()) { + userOpt = userRepository.findByUsername(emailOrUsername); + } + + if (userOpt.isPresent()) { + User user = userOpt.get(); + System.out.println("Authenticating user: " + user.getEmail()); + if (passwordEncoder.matches(password, user.getPassword())) { + System.out.println("Password matches!"); + if (!user.isActivated()) { + user.setActivated(true); + userRepository.save(user); + } + return jwtUtil.generateToken(user.getEmail()); + } + } + + throw new RuntimeException("Invalid credentials"); + } +} \ No newline at end of file diff --git a/backend/src/main/java/online/wesal/wesal/service/UserDetailsServiceImpl.java b/backend/src/main/java/online/wesal/wesal/service/UserDetailsServiceImpl.java new file mode 100644 index 0000000..513f9d0 --- /dev/null +++ b/backend/src/main/java/online/wesal/wesal/service/UserDetailsServiceImpl.java @@ -0,0 +1,30 @@ +package online.wesal.wesal.service; + +import online.wesal.wesal.entity.User; +import online.wesal.wesal.repository.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; + +@Service +public class UserDetailsServiceImpl implements UserDetailsService { + + @Autowired + private UserRepository userRepository; + + @Override + public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException { + User user = userRepository.findByEmail(email) + .orElseThrow(() -> new UsernameNotFoundException("User not found: " + email)); + + return new org.springframework.security.core.userdetails.User( + user.getEmail(), + user.getPassword(), + new ArrayList<>() + ); + } +} \ No newline at end of file diff --git a/backend/src/main/java/online/wesal/wesal/service/UserService.java b/backend/src/main/java/online/wesal/wesal/service/UserService.java new file mode 100644 index 0000000..7015dad --- /dev/null +++ b/backend/src/main/java/online/wesal/wesal/service/UserService.java @@ -0,0 +1,36 @@ +package online.wesal.wesal.service; + +import online.wesal.wesal.entity.User; +import online.wesal.wesal.repository.UserRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Service; + +@Service +public class UserService { + + @Autowired + private UserRepository userRepository; + + public User getCurrentUser() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + String email = authentication.getName(); + return userRepository.findByEmail(email) + .orElseThrow(() -> new RuntimeException("User not found")); + } + + public User setUsername(String username) { + if (userRepository.existsByUsername(username)) { + throw new RuntimeException("Username already taken"); + } + + User user = getCurrentUser(); + if (user.getUsername() != null) { + throw new RuntimeException("Username already set and cannot be changed"); + } + + user.setUsername(username); + return userRepository.save(user); + } +} \ No newline at end of file