diff --git a/.gitignore b/.gitignore index e792a47..cb5a54e 100644 --- a/.gitignore +++ b/.gitignore @@ -189,4 +189,6 @@ build/ !**/src/test/**/build/ ### VS Code ### -.vscode/ \ No newline at end of file +.vscode/ + +.env \ No newline at end of file diff --git a/pom.xml b/pom.xml index b8e53eb..b3370cb 100644 --- a/pom.xml +++ b/pom.xml @@ -38,10 +38,48 @@ org.springframework.boot spring-boot-starter-webmvc + + io.jsonwebtoken + jjwt-api + 0.12.6 + + + + io.jsonwebtoken + jjwt-impl + 0.12.6 + runtime + + + + io.jsonwebtoken + jjwt-jackson + 0.12.6 + runtime + org.springframework.boot spring-boot-starter-jdbc + + org.springframework.boot + spring-boot-starter-oauth2-resource-server + + + org.springframework.boot + spring-boot-starter-validation + + + + org.springframework.security + spring-security-oauth2-jose + + + io.github.cdimascio + java-dotenv + 5.2.2 + + org.postgresql @@ -63,6 +101,10 @@ spring-boot-starter-webmvc-test test + + org.springframework.boot + spring-boot-starter-actuator + diff --git a/src/main/java/net/hackyourfuture/security/SecurityApplication.java b/src/main/java/net/hackyourfuture/security/SecurityApplication.java index 181fefa..fe96a49 100644 --- a/src/main/java/net/hackyourfuture/security/SecurityApplication.java +++ b/src/main/java/net/hackyourfuture/security/SecurityApplication.java @@ -3,6 +3,7 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; + @SpringBootApplication public class SecurityApplication { diff --git a/src/main/java/net/hackyourfuture/security/authentication/AuthenticationService.java b/src/main/java/net/hackyourfuture/security/authentication/AuthenticationService.java index 2472a13..8d0739c 100644 --- a/src/main/java/net/hackyourfuture/security/authentication/AuthenticationService.java +++ b/src/main/java/net/hackyourfuture/security/authentication/AuthenticationService.java @@ -3,17 +3,32 @@ import lombok.AllArgsConstructor; import net.hackyourfuture.security.authentication.dto.LoginRequest; import net.hackyourfuture.security.authentication.dto.LoginResponse; +import net.hackyourfuture.security.user.User; +import net.hackyourfuture.security.user.UserRepository; +import org.springframework.http.HttpStatus; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; @Service @AllArgsConstructor public class AuthenticationService { + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + private final JwtService jwtService; + public LoginResponse login(LoginRequest request) { - throw new UnsupportedOperationException("TODO: implement login"); + User user = userRepository.findByUsername(request.username()); + + if (user == null || !passwordEncoder.matches(request.password(), user.getPassword())) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid username or password"); + } + + return new LoginResponse(jwtService.generateToken(user.getUsername())); } public void logout() { - throw new UnsupportedOperationException("TODO: implement logout"); + // JWT logout: client deletes token } -} +} \ No newline at end of file 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..930c347 --- /dev/null +++ b/src/main/java/net/hackyourfuture/security/authentication/JwtAuthenticationFilter.java @@ -0,0 +1,62 @@ +package net.hackyourfuture.security.authentication; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.AllArgsConstructor; +import net.hackyourfuture.security.user.User; +import net.hackyourfuture.security.user.UserRepository; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.List; + +@Component +@AllArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtService jwtService; + private final UserRepository userRepository; + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain + ) throws ServletException, IOException { + + String header = request.getHeader("Authorization"); + + if (header == null || !header.startsWith("Bearer ")) { + filterChain.doFilter(request, response); + return; + } + + String token = header.substring(7); + + if (!jwtService.isTokenValid(token)) { + filterChain.doFilter(request, response); + return; + } + + String username = jwtService.extractUsername(token); + User user = userRepository.findByUsername(username); + + if (user != null) { + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken( + user.getUsername(), + null, + List.of() + ); + + 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..2c7517e --- /dev/null +++ b/src/main/java/net/hackyourfuture/security/authentication/JwtService.java @@ -0,0 +1,48 @@ +package net.hackyourfuture.security.authentication; + +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.util.Date; + +@Service +public class JwtService { + + @Value("${jwt.secret}") + private String secret; + + private SecretKey getSigningKey() { + return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); + } + + public String generateToken(String username) { + return Jwts.builder() + .subject(username) + .issuedAt(new Date()) + .expiration(new Date(System.currentTimeMillis() + 1000 * 60 * 60)) + .signWith(getSigningKey()) + .compact(); + } + + public String extractUsername(String token) { + return Jwts.parser() + .verifyWith(getSigningKey()) + .build() + .parseSignedClaims(token) + .getPayload() + .getSubject(); + } + + public boolean isTokenValid(String token) { + try { + extractUsername(token); + return true; + } catch (Exception e) { + return false; + } + } +} \ No newline at end of file diff --git a/src/main/java/net/hackyourfuture/security/config/SecurityConfig.java b/src/main/java/net/hackyourfuture/security/config/SecurityConfig.java index d3ac764..f751ec0 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 lombok.AllArgsConstructor; +import net.hackyourfuture.security.authentication.JwtAuthenticationFilter; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; +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 +@AllArgsConstructor public class SecurityConfig { + private final JwtAuthenticationFilter jwtAuthenticationFilter; + @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) 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() + .requestMatchers("/users/profile", "/auth/logout").authenticated() + .anyRequest().authenticated() + ) + .addFilterBefore( + jwtAuthenticationFilter, + UsernamePasswordAuthenticationFilter.class + ); + return http.build(); } -} + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(12); + } +} \ No newline at end of file diff --git a/src/main/java/net/hackyourfuture/security/user/UserController.java b/src/main/java/net/hackyourfuture/security/user/UserController.java index 9309baf..3610b06 100644 --- a/src/main/java/net/hackyourfuture/security/user/UserController.java +++ b/src/main/java/net/hackyourfuture/security/user/UserController.java @@ -10,6 +10,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; +import org.springframework.security.core.Authentication; @RestController @RequestMapping("/users") @@ -25,7 +26,7 @@ public UserResponse register(@RequestBody UserRequest 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..74b8912 100644 --- a/src/main/java/net/hackyourfuture/security/user/UserService.java +++ b/src/main/java/net/hackyourfuture/security/user/UserService.java @@ -4,24 +4,44 @@ import net.hackyourfuture.security.user.dto.UserRequest; import net.hackyourfuture.security.user.dto.UserResponse; import org.springframework.http.HttpStatus; +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"); + User existingUser = userRepository.findByUsername(request.username()); + + if (existingUser != null) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "Username already exists"); + } + + User user = new User( + UUID.randomUUID().toString(), + request.username(), + passwordEncoder.encode(request.password()) + ); + + userRepository.createUser(user); + + return new UserResponse(user.getId(), user.getUsername()); } public UserResponse getProfile(String username) { User user = userRepository.findByUsername(username); + if (user == null) { throw new ResponseStatusException(HttpStatus.NOT_FOUND, "User not found"); } + return new UserResponse(user.getId(), user.getUsername()); } } diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 098cbeb..701bfd9 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -2,9 +2,14 @@ spring: application: name: security datasource: - url: '${DB_URL}' - username: '${DB_USERNAME}' - password: '${DB_PASSWORD}' + url: ${DB_URL} + username: ${DB_USERNAME} + password: ${DB_PASSWORD} + driver-class-name: org.postgresql.Driver + jwt: + secret: ${JWT_SECRET} sql: init: mode: always + +