diff --git a/README.md b/README.md index 0559568..1a5d80e 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,10 @@ - [GraalVM 23](https://www.graalvm.org/) +## Generate sources + +To generate code from the API contract, run `./mvnw generate-sources` + ## Build native executable 1. Make sure your `JAVA_HOME` or `GRAALVM_HOME` environment variable is correctly diff --git a/pom.xml b/pom.xml index 38d28f2..7ccbbb4 100644 --- a/pom.xml +++ b/pom.xml @@ -39,6 +39,10 @@ org.springframework.boot spring-boot-starter-web + + org.springframework.boot + spring-boot-starter-security + org.flywaydb flyway-database-postgresql @@ -73,6 +77,23 @@ swagger-models 2.2.28 + + io.jsonwebtoken + jjwt-api + 0.11.5 + + + io.jsonwebtoken + jjwt-impl + 0.11.5 + runtime + + + io.jsonwebtoken + jjwt-jackson + 0.11.5 + runtime + @@ -163,6 +184,7 @@ ${project.basedir}/openapi.yml spring + ch.dlmw.gen true true diff --git a/src/main/java/ch/dlmw/swisssignchallenge/UserCreator.java b/src/main/java/ch/dlmw/swisssignchallenge/UserCreator.java new file mode 100644 index 0000000..4ee12b0 --- /dev/null +++ b/src/main/java/ch/dlmw/swisssignchallenge/UserCreator.java @@ -0,0 +1,31 @@ +package ch.dlmw.swisssignchallenge; + +import ch.dlmw.swisssignchallenge.entities.User; +import ch.dlmw.swisssignchallenge.repositories.UserRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.CommandLineRunner; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Bean; +import org.springframework.security.crypto.password.PasswordEncoder; + +@SpringBootApplication +public class UserCreator { + + @Autowired + private PasswordEncoder passwordEncoder; + + public static void main(String[] args) { + SpringApplication.run(UserCreator.class, args); + } + + @Bean + public CommandLineRunner commandLineRunner(UserRepository userRepository) { + return args -> { + var user = new User(); + user.setUsername("john"); + user.setPasswordHash(passwordEncoder.encode("password")); + userRepository.save(user); + }; + } +} diff --git a/src/main/java/ch/dlmw/swisssignchallenge/config/SecurityConfig.java b/src/main/java/ch/dlmw/swisssignchallenge/config/SecurityConfig.java new file mode 100644 index 0000000..8f3dbab --- /dev/null +++ b/src/main/java/ch/dlmw/swisssignchallenge/config/SecurityConfig.java @@ -0,0 +1,55 @@ +package ch.dlmw.swisssignchallenge.config; + +import ch.dlmw.swisssignchallenge.filters.JwtRequestFilter; +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.builders.AuthenticationManagerBuilder; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +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; + +@Configuration +@EnableWebSecurity +public class SecurityConfig { + private final UserDetailsService userDetailsService; + + private final JwtRequestFilter jwtRequestFilter; + + public SecurityConfig(UserDetailsService userDetailsService, JwtRequestFilter jwtRequestFilter) { + this.userDetailsService = userDetailsService; + this.jwtRequestFilter = jwtRequestFilter; + } + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + .authorizeHttpRequests(auth -> auth + .requestMatchers("/authenticate", "/register").permitAll() // Public endpoints + .anyRequest().authenticated() // All other endpoints require authentication + ) + .csrf(AbstractHttpConfigurer::disable) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)); + + http.addFilterBefore(jwtRequestFilter, UsernamePasswordAuthenticationFilter.class); + return http.build(); + } + + @Bean + public AuthenticationManager authenticationManager(HttpSecurity http) throws Exception { + AuthenticationManagerBuilder auth = http.getSharedObject(AuthenticationManagerBuilder.class); + auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder()); + return auth.build(); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} diff --git a/src/main/java/ch/dlmw/swisssignchallenge/entities/User.java b/src/main/java/ch/dlmw/swisssignchallenge/entities/User.java new file mode 100644 index 0000000..37d46b2 --- /dev/null +++ b/src/main/java/ch/dlmw/swisssignchallenge/entities/User.java @@ -0,0 +1,67 @@ +package ch.dlmw.swisssignchallenge.entities; + +import jakarta.persistence.*; +import org.hibernate.annotations.GenericGenerator; + +import java.util.UUID; + +@Entity +@Table(name = "users") +public class User { + + @Id + @GeneratedValue(generator = "UUID") + @GenericGenerator( + name = "UUID", + strategy = "org.hibernate.id.UUIDGenerator" + ) + @Column(name = "user_id", updatable = false, nullable = false) + private UUID userId; + + @Column(name = "username", unique = true, nullable = false, length = 50) + private String username; + + @Column(name = "password_hash", nullable = false) + private String passwordHash; + + public User() { + } + + public User(String username, String passwordHash) { + this.username = username; + this.passwordHash = passwordHash; + } + + public UUID getUserId() { + return userId; + } + + public void setUserId(UUID userId) { + this.userId = userId; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getPasswordHash() { + return passwordHash; + } + + public void setPasswordHash(String passwordHash) { + this.passwordHash = passwordHash; + } + + @Override + public String toString() { + return "User{" + + "userId=" + userId + + ", username='" + username + '\'' + + ", passwordHash='" + passwordHash + '\'' + + '}'; + } +} \ No newline at end of file diff --git a/src/main/java/ch/dlmw/swisssignchallenge/filters/JwtRequestFilter.java b/src/main/java/ch/dlmw/swisssignchallenge/filters/JwtRequestFilter.java new file mode 100644 index 0000000..72cc923 --- /dev/null +++ b/src/main/java/ch/dlmw/swisssignchallenge/filters/JwtRequestFilter.java @@ -0,0 +1,53 @@ +package ch.dlmw.swisssignchallenge.filters; + +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.web.filter.OncePerRequestFilter; +import ch.dlmw.swisssignchallenge.utils.JwtUtil; + +import java.io.IOException; + +@Component +public class JwtRequestFilter extends OncePerRequestFilter { + + @Autowired + private UserDetailsService userDetailsService; + + @Autowired + private JwtUtil jwtUtil; + + @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); + username = jwtUtil.extractUsername(jwt); + } + + if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) { + UserDetails userDetails = this.userDetailsService.loadUserByUsername(username); + + if (jwtUtil.validateToken(jwt, userDetails)) { + UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken( + userDetails, null, userDetails.getAuthorities()); + authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + SecurityContextHolder.getContext().setAuthentication(authenticationToken); + } + } + + filterChain.doFilter(request, response); + } +} diff --git a/src/main/java/ch/dlmw/swisssignchallenge/repositories/UserRepository.java b/src/main/java/ch/dlmw/swisssignchallenge/repositories/UserRepository.java new file mode 100644 index 0000000..23c9861 --- /dev/null +++ b/src/main/java/ch/dlmw/swisssignchallenge/repositories/UserRepository.java @@ -0,0 +1,13 @@ +package ch.dlmw.swisssignchallenge.repositories; + +import ch.dlmw.swisssignchallenge.entities.User; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; +import java.util.UUID; + +@Repository +public interface UserRepository extends JpaRepository { + Optional findByUsername(String username); +} diff --git a/src/main/java/ch/dlmw/swisssignchallenge/services/UserDetailsServiceImpl.java b/src/main/java/ch/dlmw/swisssignchallenge/services/UserDetailsServiceImpl.java new file mode 100644 index 0000000..207b533 --- /dev/null +++ b/src/main/java/ch/dlmw/swisssignchallenge/services/UserDetailsServiceImpl.java @@ -0,0 +1,29 @@ +package ch.dlmw.swisssignchallenge.services; + +import ch.dlmw.swisssignchallenge.entities.User; +import ch.dlmw.swisssignchallenge.repositories.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 +public class UserDetailsServiceImpl implements UserDetailsService { + private final UserRepository userRepository; + + public UserDetailsServiceImpl(UserRepository userRepository) { + this.userRepository = userRepository; + } + + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + User user = userRepository.findByUsername(username) + .orElseThrow(() -> new UsernameNotFoundException("User not found with username: " + username)); + + return org.springframework.security.core.userdetails.User + .withUsername(user.getUsername()) + .password(user.getPasswordHash()) +// .roles("USER") + .build(); + } +} diff --git a/src/main/java/ch/dlmw/swisssignchallenge/utils/JwtUtil.java b/src/main/java/ch/dlmw/swisssignchallenge/utils/JwtUtil.java new file mode 100644 index 0000000..25272f2 --- /dev/null +++ b/src/main/java/ch/dlmw/swisssignchallenge/utils/JwtUtil.java @@ -0,0 +1,66 @@ +package ch.dlmw.swisssignchallenge.utils; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.security.Keys; +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 JwtUtil { + + private static final Key SECRET_KEY = Keys.secretKeyFor(SignatureAlgorithm.HS256); // Generate a secure key + private static final long EXPIRATION_TIME = 864_000_000; // 10 days in milliseconds + + public String generateToken(UserDetails userDetails) { + var claims = new HashMap(); + return createToken(claims, userDetails.getUsername()); + } + + private String createToken(Map claims, String subject) { + return Jwts.builder() + .setClaims(claims) + .setSubject(subject) + .setIssuedAt(new Date(System.currentTimeMillis())) + .setExpiration(new Date(System.currentTimeMillis() + EXPIRATION_TIME)) + .signWith(SECRET_KEY) + .compact(); + } + + public Boolean validateToken(String token, UserDetails userDetails) { + final String username = extractUsername(token); + return (username.equals(userDetails.getUsername()) && !isTokenExpired(token)); + } + + public String extractUsername(String token) { + return extractClaim(token, Claims::getSubject); + } + + public Date extractExpiration(String token) { + return extractClaim(token, Claims::getExpiration); + } + + private T extractClaim(String token, Function claimsResolver) { + final Claims claims = extractAllClaims(token); + return claimsResolver.apply(claims); + } + + private Claims extractAllClaims(String token) { + return Jwts.parserBuilder() + .setSigningKey(SECRET_KEY) + .build() + .parseClaimsJws(token) + .getBody(); + } + + private Boolean isTokenExpired(String token) { + return extractExpiration(token).before(new Date()); + } +} diff --git a/src/main/resources/db/migration/V1__init.sql b/src/main/resources/db/migration/V1__init.sql index 3d1ec12..6725a2d 100644 --- a/src/main/resources/db/migration/V1__init.sql +++ b/src/main/resources/db/migration/V1__init.sql @@ -1,4 +1,5 @@ -CREATE TABLE example_table ( - id SERIAL PRIMARY KEY, - name VARCHAR(255) NOT NULL -); +CREATE TABLE users ( + user_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + username VARCHAR(50) UNIQUE NOT NULL, + password_hash TEXT NOT NULL +); \ No newline at end of file