From 976810c7e997a8f8c80284037f79a7dbcd1b7d29 Mon Sep 17 00:00:00 2001 From: Yana Pechenenko Date: Wed, 8 Jul 2026 15:57:36 +0200 Subject: [PATCH] feat: implement JWT authentication system with register/login/logout/profile --- pom.xml | 18 +++++- .../authentication/AppUserService.java | 33 +++++++++++ .../AuthenticationController.java | 1 + .../authentication/AuthenticationService.java | 15 ++++- .../JwtAuthenticationFilter.java | 57 +++++++++++++++++++ .../security/authentication/JwtService.java | 57 +++++++++++++++++++ .../security/config/SecurityConfig.java | 30 +++++++++- .../security/user/UserController.java | 8 ++- .../security/user/UserService.java | 13 ++++- src/main/resources/application.yaml | 4 ++ 10 files changed, 228 insertions(+), 8 deletions(-) create mode 100644 src/main/java/net/hackyourfuture/security/authentication/AppUserService.java create mode 100644 src/main/java/net/hackyourfuture/security/authentication/JwtAuthenticationFilter.java create mode 100644 src/main/java/net/hackyourfuture/security/authentication/JwtService.java diff --git a/pom.xml b/pom.xml index b8e53eb..fb831e3 100644 --- a/pom.xml +++ b/pom.xml @@ -42,7 +42,23 @@ org.springframework.boot spring-boot-starter-jdbc - + + io.jsonwebtoken + jjwt-api + 0.12.6 + + + io.jsonwebtoken + jjwt-impl + 0.12.6 + runtime + + + io.jsonwebtoken + jjwt-jackson + 0.12.6 + runtime + org.postgresql postgresql diff --git a/src/main/java/net/hackyourfuture/security/authentication/AppUserService.java b/src/main/java/net/hackyourfuture/security/authentication/AppUserService.java new file mode 100644 index 0000000..da36705 --- /dev/null +++ b/src/main/java/net/hackyourfuture/security/authentication/AppUserService.java @@ -0,0 +1,33 @@ +package net.hackyourfuture.security.authentication; + + +import lombok.AllArgsConstructor; +import net.hackyourfuture.security.user.User; +import net.hackyourfuture.security.user.UserRepository; +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; + + +@Service +@AllArgsConstructor +public class AppUserService implements UserDetailsService { + + private final UserRepository userRepository; + + @Override + public UserDetails loadUserByUsername(String username){ + User user = userRepository.findByUsername(username); + + if(user == null){ + throw new UsernameNotFoundException("User not found: " + username); + } + + return org.springframework.security.core.userdetails.User.builder() + .username(user.getUsername()) + .password(user.getPassword()) + .authorities("USER") + .build(); + } +} diff --git a/src/main/java/net/hackyourfuture/security/authentication/AuthenticationController.java b/src/main/java/net/hackyourfuture/security/authentication/AuthenticationController.java index e25fd2c..85ef3ea 100644 --- a/src/main/java/net/hackyourfuture/security/authentication/AuthenticationController.java +++ b/src/main/java/net/hackyourfuture/security/authentication/AuthenticationController.java @@ -17,6 +17,7 @@ public class AuthenticationController { @PostMapping("/login") public LoginResponse login(@RequestBody LoginRequest request) { + return authenticationService.login(request); } diff --git a/src/main/java/net/hackyourfuture/security/authentication/AuthenticationService.java b/src/main/java/net/hackyourfuture/security/authentication/AuthenticationService.java index 2472a13..a107c71 100644 --- a/src/main/java/net/hackyourfuture/security/authentication/AuthenticationService.java +++ b/src/main/java/net/hackyourfuture/security/authentication/AuthenticationService.java @@ -3,17 +3,28 @@ import lombok.AllArgsConstructor; import net.hackyourfuture.security.authentication.dto.LoginRequest; import net.hackyourfuture.security.authentication.dto.LoginResponse; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.userdetails.UserDetails; import org.springframework.stereotype.Service; @Service @AllArgsConstructor public class AuthenticationService { + private final AuthenticationManager authenticationManager; + private final JwtService jwtService; + public LoginResponse login(LoginRequest request) { - throw new UnsupportedOperationException("TODO: implement login"); + Authentication authentication = authenticationManager.authenticate( + new UsernamePasswordAuthenticationToken(request.username(), request.password())); + + UserDetails user = (UserDetails) authentication.getPrincipal(); + String token = jwtService.generateToken(user); + return new LoginResponse(token); } public void logout() { - throw new UnsupportedOperationException("TODO: implement logout"); } } diff --git a/src/main/java/net/hackyourfuture/security/authentication/JwtAuthenticationFilter.java b/src/main/java/net/hackyourfuture/security/authentication/JwtAuthenticationFilter.java new file mode 100644 index 0000000..9c87483 --- /dev/null +++ b/src/main/java/net/hackyourfuture/security/authentication/JwtAuthenticationFilter.java @@ -0,0 +1,57 @@ +package net.hackyourfuture.security.authentication; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +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.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@Component +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtService jwtService; + private final UserDetailsService userDetailsService; + + public JwtAuthenticationFilter(JwtService jwtService, UserDetailsService userDetailsService) { + this.jwtService = jwtService; + this.userDetailsService = userDetailsService; + } + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + + String authHeader = request.getHeader("Authorization"); + + if (authHeader == null || !authHeader.startsWith("Bearer ")) { + filterChain.doFilter(request, response); + return; + } + + String token = authHeader.substring(7); + String username = jwtService.extractUsername(token); + + if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) { + UserDetails userDetails = userDetailsService.loadUserByUsername(username); + + if (jwtService.isTokenValid(token, userDetails)) { + var authentication = new UsernamePasswordAuthenticationToken( + userDetails, + null, + userDetails.getAuthorities()); + + SecurityContextHolder.getContext().setAuthentication(authentication); + } + } + + filterChain.doFilter(request, response); + } +} \ No newline at end of file diff --git a/src/main/java/net/hackyourfuture/security/authentication/JwtService.java b/src/main/java/net/hackyourfuture/security/authentication/JwtService.java new file mode 100644 index 0000000..4d55206 --- /dev/null +++ b/src/main/java/net/hackyourfuture/security/authentication/JwtService.java @@ -0,0 +1,57 @@ +package net.hackyourfuture.security.authentication; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Service; + +import javax.crypto.SecretKey; +import java.util.Date; +import java.util.function.Function; + +@Service +public class JwtService { + + @Value("${jwt.secret}") + private String secretKey; + + @Value("${jwt.expiration-ms}") + private long expirationMs; + + private SecretKey getSigningKey() { + return Keys.hmacShaKeyFor(secretKey.getBytes()); + } + + public String generateToken(UserDetails user) { + return Jwts.builder() + .subject(user.getUsername()) + .issuedAt(new Date()) + .expiration(new Date(System.currentTimeMillis() + expirationMs)) + .signWith(getSigningKey()) + .compact(); + } + + public String extractUsername(String token) { + return extractClaim(token, Claims::getSubject); + } + + public boolean isTokenValid(String token, UserDetails user) { + final String username = extractUsername(token); + return username.equals(user.getUsername()) && !isTokenExpired(token); + } + + private boolean isTokenExpired(String token) { + return extractClaim(token, Claims::getExpiration).before(new Date()); + } + + private T extractClaim(String token, Function resolver) { + Claims claims = Jwts.parser() + .verifyWith(getSigningKey()) + .build() + .parseSignedClaims(token) + .getPayload(); + return resolver.apply(claims); + } +} diff --git a/src/main/java/net/hackyourfuture/security/config/SecurityConfig.java b/src/main/java/net/hackyourfuture/security/config/SecurityConfig.java index d3ac764..097e446 100644 --- a/src/main/java/net/hackyourfuture/security/config/SecurityConfig.java +++ b/src/main/java/net/hackyourfuture/security/config/SecurityConfig.java @@ -1,18 +1,44 @@ package net.hackyourfuture.security.config; +import net.hackyourfuture.security.authentication.JwtAuthenticationFilter; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationManager; +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.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; @Configuration +@EnableWebSecurity +@EnableMethodSecurity public class SecurityConfig { @Bean - public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + public SecurityFilterChain securityFilterChain(HttpSecurity http, JwtAuthenticationFilter jwtFilter) throws Exception { http .csrf(csrf -> csrf.disable()) - .authorizeHttpRequests(auth -> auth.anyRequest().permitAll()); + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(auth -> auth + .requestMatchers("/users/register", "/auth/login").permitAll() + .anyRequest().authenticated()) + .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class); + return http.build(); } + + @Bean + public PasswordEncoder passwordEncoder(){ + return new BCryptPasswordEncoder(12); + } + + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception{ + return config.getAuthenticationManager(); + } } diff --git a/src/main/java/net/hackyourfuture/security/user/UserController.java b/src/main/java/net/hackyourfuture/security/user/UserController.java index 9309baf..9f3af37 100644 --- a/src/main/java/net/hackyourfuture/security/user/UserController.java +++ b/src/main/java/net/hackyourfuture/security/user/UserController.java @@ -4,6 +4,7 @@ import net.hackyourfuture.security.user.dto.UserRequest; import net.hackyourfuture.security.user.dto.UserResponse; import org.springframework.http.HttpStatus; +import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -24,8 +25,11 @@ public UserResponse register(@RequestBody UserRequest request) { return userService.register(request); } + + @GetMapping("/profile") - public UserResponse profile() { - return userService.getProfile("REPLACE WITH CURRENTLY LOGGED IN USER ID"); + public UserResponse profile(Authentication authentication) { + + return userService.getProfile(authentication.getName()); } } diff --git a/src/main/java/net/hackyourfuture/security/user/UserService.java b/src/main/java/net/hackyourfuture/security/user/UserService.java index 15afd1e..e09d1ee 100644 --- a/src/main/java/net/hackyourfuture/security/user/UserService.java +++ b/src/main/java/net/hackyourfuture/security/user/UserService.java @@ -4,19 +4,30 @@ import net.hackyourfuture.security.user.dto.UserRequest; import net.hackyourfuture.security.user.dto.UserResponse; import org.springframework.http.HttpStatus; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.web.server.ResponseStatusException; +import java.util.UUID; + @Service @AllArgsConstructor public class UserService { private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; public UserResponse register(UserRequest request) { - throw new UnsupportedOperationException("TODO: implement registration"); + String encoderPassword = passwordEncoder.encode(request.password()); + User newUser = new User(UUID.randomUUID().toString(), request.username(), encoderPassword); + + userRepository.createUser(newUser); + + return new UserResponse(newUser.getId(), newUser.getUsername()); } + @PreAuthorize("#username == authentication.name") public UserResponse getProfile(String username) { User user = userRepository.findByUsername(username); if (user == null) { diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 098cbeb..280d8e4 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -8,3 +8,7 @@ spring: sql: init: mode: always + +jwt: + secret: '${JWT_SECRET}' + expiration-ms: 900000