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