feat: add users and user login to backend

This commit is contained in:
sBubshait 2025-07-20 15:59:24 +03:00
parent c78f246d96
commit 00fe49b109
14 changed files with 645 additions and 0 deletions

View File

@ -68,6 +68,26 @@
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.3</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.3</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.3</version>
</dependency>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.3.0</version>
</dependency>
</dependencies>
<build>

View File

@ -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);
}
}

View File

@ -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> T extractClaim(String token, Function<Claims, T> 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));
}
}

View File

@ -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")));
}
}

View File

@ -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();
}
}

View File

@ -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<Map<String, Integer>> 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<LoginResponse> 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<User> 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<User> getUser() {
try {
User user = userService.getCurrentUser();
return ResponseEntity.ok(user);
} catch (RuntimeException e) {
return ResponseEntity.badRequest().build();
}
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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<User, Long> {
Optional<User> findByEmail(String email);
Optional<User> findByUsername(String username);
boolean existsByEmail(String email);
boolean existsByUsername(String username);
}

View File

@ -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<User> 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");
}
}

View File

@ -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<>()
);
}
}

View File

@ -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);
}
}