From 500df2963ea422ae07846faa5aa0131b581cb872 Mon Sep 17 00:00:00 2001 From: Claas Date: Tue, 19 May 2026 15:38:50 +0200 Subject: [PATCH] Copy code from yealch attempt --- server/.gitignore | 10 +- server/.mvn/wrapper/maven-wrapper.properties | 6 +- server/pom.xml | 72 ++++++++++----- .../com/yealch/yealch/AuthController.java | 91 +++++++++++++++++++ .../yealch/CustomUserDetailsService.java | 26 ++++++ .../yealch/JwtAuthenticationFilter.java | 70 ++++++++++++++ .../java/com/yealch/yealch/JwtService.java | 65 +++++++++++++ .../com/yealch/yealch/SecurityConfig.java | 55 +++++++++++ .../src/main/java/com/yealch/yealch/User.java | 48 ++++++++++ .../com/yealch/yealch/UserRepository.java | 11 +++ .../com/yealch/yealch/UsersController.java | 19 ++++ .../com/yealch/yealch/YealchApplication.java | 38 ++++++++ .../java/dev/claas/flat/FlatApplication.java | 28 ------ .../src/main/resources/application.properties | 9 +- .../yealch/YealchApplicationTests.java} | 4 +- 15 files changed, 487 insertions(+), 65 deletions(-) create mode 100644 server/src/main/java/com/yealch/yealch/AuthController.java create mode 100644 server/src/main/java/com/yealch/yealch/CustomUserDetailsService.java create mode 100644 server/src/main/java/com/yealch/yealch/JwtAuthenticationFilter.java create mode 100644 server/src/main/java/com/yealch/yealch/JwtService.java create mode 100644 server/src/main/java/com/yealch/yealch/SecurityConfig.java create mode 100644 server/src/main/java/com/yealch/yealch/User.java create mode 100644 server/src/main/java/com/yealch/yealch/UserRepository.java create mode 100644 server/src/main/java/com/yealch/yealch/UsersController.java create mode 100644 server/src/main/java/com/yealch/yealch/YealchApplication.java delete mode 100644 server/src/main/java/dev/claas/flat/FlatApplication.java rename server/src/test/java/{dev/claas/flat/FlatApplicationTests.java => com/yealch/yealch/YealchApplicationTests.java} (72%) diff --git a/server/.gitignore b/server/.gitignore index c7c56d6..667aaef 100644 --- a/server/.gitignore +++ b/server/.gitignore @@ -4,7 +4,7 @@ target/ !**/src/main/**/target/ !**/src/test/**/target/ -# STS +### STS ### .apt_generated .classpath .factorypath @@ -13,13 +13,13 @@ target/ .springBeans .sts4-cache -# IntelliJ IDEA +### IntelliJ IDEA ### .idea *.iws *.iml *.ipr -# NetBeans +### NetBeans ### /nbproject/private/ /nbbuild/ /dist/ @@ -29,5 +29,5 @@ build/ !**/src/main/**/build/ !**/src/test/**/build/ -# MacOS specific -.DS_Store \ No newline at end of file +### VS Code ### +.vscode/ diff --git a/server/.mvn/wrapper/maven-wrapper.properties b/server/.mvn/wrapper/maven-wrapper.properties index 22f7b39..5291372 100644 --- a/server/.mvn/wrapper/maven-wrapper.properties +++ b/server/.mvn/wrapper/maven-wrapper.properties @@ -1,7 +1,3 @@ wrapperVersion=3.3.4 distributionType=only-script -distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.14/apache-maven-3.9.14-bin.zip - -spring.datasource.url=jdbc:hsqldb:hsql://localhost -spring.jpa.hibernate.ddl-auto=update -spring.jpa.properties.hibernate.dialect = org.hibernate.dialect.HSQLDialect \ No newline at end of file +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.15/apache-maven-3.9.15-bin.zip diff --git a/server/pom.xml b/server/pom.xml index 7ecece0..712343b 100644 --- a/server/pom.xml +++ b/server/pom.xml @@ -1,32 +1,30 @@ - 4.0.0 org.springframework.boot spring-boot-starter-parent - 4.0.5 - - + 4.0.6 + - dev.claas - flat + com.yealch + yealch 0.0.1-SNAPSHOT - - - + + + - + - + - - - - + + + + 25 @@ -34,22 +32,48 @@ org.springframework.boot - spring-boot-starter-webmvc + spring-boot-starter-security + + + 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-webmvc-test - test + spring-boot-h2console - org.springframework.boot spring-boot-starter-data-jpa + + + com.h2database + h2 + runtime + + + org.springframework.boot + spring-boot-starter-data-jpa-test + test + - org.hsqldb - hsqldb + org.springframework.boot + spring-boot-starter-web + compile @@ -62,4 +86,4 @@ - \ No newline at end of file + diff --git a/server/src/main/java/com/yealch/yealch/AuthController.java b/server/src/main/java/com/yealch/yealch/AuthController.java new file mode 100644 index 0000000..0e88e50 --- /dev/null +++ b/server/src/main/java/com/yealch/yealch/AuthController.java @@ -0,0 +1,91 @@ +package com.yealch.yealch; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseCookie; +import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.userdetails.User; +import org.springframework.web.bind.annotation.*; + +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Base64; +import java.util.Map; + +@RestController +@RequestMapping("/api") +public class AuthController { + + private final AuthenticationManager authenticationManager; + private final JwtService jwtService; + private final Logger logger = LoggerFactory.getLogger(AuthController.class); + + public AuthController(AuthenticationManager authenticationManager, JwtService jwtService) { + this.authenticationManager = authenticationManager; + this.jwtService = jwtService; + } + + + @PostMapping("/login") + public ResponseEntity login( + // Setting required to false to return 401 instead of 400 + @RequestHeader(value = HttpHeaders.AUTHORIZATION, required = false) String authorizationHeader) { + String base64Credentials = authorizationHeader.substring("Basic ".length()); + String credentials; + try { + credentials = new String(Base64.getDecoder().decode(base64Credentials), StandardCharsets.UTF_8); + } catch (IllegalArgumentException e) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + + int index = credentials.indexOf(':'); + if (index == -1) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + + String username = credentials.substring(0, index); + String password = credentials.substring(index + 1); + + logger.info("Login attempt for user: {}", username); + + Authentication authentication = authenticationManager.authenticate( + new UsernamePasswordAuthenticationToken(username, password)); + User userDetails = (User) authentication.getPrincipal(); + + String token = jwtService.generateToken(userDetails); + ResponseCookie cookie = ResponseCookie.from(JwtService.COOKIE_NAME, token) + .httpOnly(true) + // Disable this as localhost is not seen as secure in Safari + .secure(true) + .path("/") + // API is proxied in localhost development and frontend is hosted by this server + // in production + .sameSite("Strict") + .maxAge(Duration.ofMinutes(60)) + .build(); + + return ResponseEntity.noContent() + .header(HttpHeaders.SET_COOKIE, cookie.toString()) + .build(); + } + + @PostMapping("/logout") + public ResponseEntity logout() { + ResponseCookie cookie = ResponseCookie.from(JwtService.COOKIE_NAME, "") + .httpOnly(true) + .secure(false) + .path("/") + .sameSite("Strict") + .maxAge(Duration.ZERO) + .build(); + + return ResponseEntity.ok() + .header(HttpHeaders.SET_COOKIE, cookie.toString()) + .body(Map.of("status", "logged out")); + } +} diff --git a/server/src/main/java/com/yealch/yealch/CustomUserDetailsService.java b/server/src/main/java/com/yealch/yealch/CustomUserDetailsService.java new file mode 100644 index 0000000..4105c3d --- /dev/null +++ b/server/src/main/java/com/yealch/yealch/CustomUserDetailsService.java @@ -0,0 +1,26 @@ +package com.yealch.yealch; + +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 CustomUserDetailsService implements UserDetailsService { + + private final UserRepository userRepository; + + public CustomUserDetailsService(UserRepository userRepository) { + this.userRepository = userRepository; + } + + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + var user = userRepository.findByUsername(username) + .orElseThrow(() -> new UsernameNotFoundException("User not found: " + username)); + return org.springframework.security.core.userdetails.User.withUsername(user.getUsername()) + .password(user.getPassword()) + .roles("USER") + .build(); + } +} diff --git a/server/src/main/java/com/yealch/yealch/JwtAuthenticationFilter.java b/server/src/main/java/com/yealch/yealch/JwtAuthenticationFilter.java new file mode 100644 index 0000000..16fd3bb --- /dev/null +++ b/server/src/main/java/com/yealch/yealch/JwtAuthenticationFilter.java @@ -0,0 +1,70 @@ +package com.yealch.yealch; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.Cookie; +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.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 { + + private final JwtService jwtService; + private final CustomUserDetailsService userDetailsService; + + public JwtAuthenticationFilter(JwtService jwtService, CustomUserDetailsService userDetailsService) { + this.jwtService = jwtService; + this.userDetailsService = userDetailsService; + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + if (SecurityContextHolder.getContext().getAuthentication() == null) { + String token = extractToken(request.getCookies()); + + if (token != null) { + try { + String username = jwtService.extractUsername(token); + UserDetails userDetails = userDetailsService.loadUserByUsername(username); + + if (jwtService.isTokenValid(token, userDetails)) { + UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( + userDetails, + null, + userDetails.getAuthorities()); + authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + } catch (Exception ignored) { + SecurityContextHolder.clearContext(); + } + } + } + + filterChain.doFilter(request, response); + } + + private String extractToken(Cookie[] cookies) { + if (cookies == null) { + return null; + } + + for (Cookie cookie : cookies) { + if (JwtService.COOKIE_NAME.equals(cookie.getName())) { + return cookie.getValue(); + } + } + + return null; + } +} diff --git a/server/src/main/java/com/yealch/yealch/JwtService.java b/server/src/main/java/com/yealch/yealch/JwtService.java new file mode 100644 index 0000000..4f8e928 --- /dev/null +++ b/server/src/main/java/com/yealch/yealch/JwtService.java @@ -0,0 +1,65 @@ +package com.yealch.yealch; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.io.Decoders; +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.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Date; + +@Service +public class JwtService { + + public static final String COOKIE_NAME = "AUTH_TOKEN"; + + private final SecretKey signingKey; + private final long expirationMillis; + + public JwtService(@Value("${app.jwt.secret}") String base64Secret, + @Value("${app.jwt.expiration-minutes:60}") long expirationMinutes) { + byte[] keyBytes; + try { + keyBytes = Decoders.BASE64.decode(base64Secret); + } catch (IllegalArgumentException ex) { + keyBytes = base64Secret.getBytes(StandardCharsets.UTF_8); + } + + this.signingKey = Keys.hmacShaKeyFor(keyBytes); + this.expirationMillis = Duration.ofMinutes(expirationMinutes).toMillis(); + } + + public String generateToken(UserDetails userDetails) { + Date now = new Date(); + Date expiration = new Date(now.getTime() + expirationMillis); + + return Jwts.builder() + .subject(userDetails.getUsername()) + .issuedAt(now) + .expiration(expiration) + .signWith(signingKey) + .compact(); + } + + public String extractUsername(String token) { + return extractClaims(token).getSubject(); + } + + public boolean isTokenValid(String token, UserDetails userDetails) { + Claims claims = extractClaims(token); + return userDetails.getUsername().equals(claims.getSubject()) && !claims.getExpiration().before(new Date()); + } + + private Claims extractClaims(String token) { + return Jwts.parser() + .verifyWith(signingKey) + .build() + .parseSignedClaims(token) + .getPayload(); + } +} diff --git a/server/src/main/java/com/yealch/yealch/SecurityConfig.java b/server/src/main/java/com/yealch/yealch/SecurityConfig.java new file mode 100644 index 0000000..9a71253 --- /dev/null +++ b/server/src/main/java/com/yealch/yealch/SecurityConfig.java @@ -0,0 +1,55 @@ +package com.yealch.yealch; + +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.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 +public class SecurityConfig { + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public DaoAuthenticationProvider authenticationProvider(CustomUserDetailsService userDetailsService, + PasswordEncoder passwordEncoder) { + DaoAuthenticationProvider provider = new DaoAuthenticationProvider(userDetailsService); + provider.setPasswordEncoder(passwordEncoder); + return provider; + } + + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) + throws Exception { + return authenticationConfiguration.getAuthenticationManager(); + } + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http, + JwtAuthenticationFilter jwtAuthenticationFilter, + DaoAuthenticationProvider authenticationProvider) throws Exception { + http + .csrf(csrf -> csrf.disable()) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authenticationProvider(authenticationProvider) + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) + .authorizeHttpRequests(auth -> auth + .requestMatchers("/api/login", "/api/logout", "/h2-console/**", "/error").permitAll() + .anyRequest().authenticated()) + .headers(headers -> headers.frameOptions(frame -> frame.disable())) + .formLogin(form -> form.disable()) + .httpBasic(basic -> basic.disable()); + + return http.build(); + } +} diff --git a/server/src/main/java/com/yealch/yealch/User.java b/server/src/main/java/com/yealch/yealch/User.java new file mode 100644 index 0000000..e5bb7aa --- /dev/null +++ b/server/src/main/java/com/yealch/yealch/User.java @@ -0,0 +1,48 @@ +package com.yealch.yealch; + +import jakarta.persistence.*; + +@Entity +@Table(name = "users") +public class User { + @Id + @GeneratedValue(strategy = GenerationType.AUTO) + private Long id; + + @Column(nullable = false) + private String name; + + @Column(nullable = false, unique = true) + private String username; + + @Column(nullable = false) + private String password; + + public Long getId() { + return id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + 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; + } +} diff --git a/server/src/main/java/com/yealch/yealch/UserRepository.java b/server/src/main/java/com/yealch/yealch/UserRepository.java new file mode 100644 index 0000000..d44329c --- /dev/null +++ b/server/src/main/java/com/yealch/yealch/UserRepository.java @@ -0,0 +1,11 @@ +package com.yealch.yealch; + +import org.springframework.data.repository.CrudRepository; + +import java.util.Optional; + +public interface UserRepository extends CrudRepository { + Optional findById(Long id); + + Optional findByUsername(String username); +} diff --git a/server/src/main/java/com/yealch/yealch/UsersController.java b/server/src/main/java/com/yealch/yealch/UsersController.java new file mode 100644 index 0000000..c5c4049 --- /dev/null +++ b/server/src/main/java/com/yealch/yealch/UsersController.java @@ -0,0 +1,19 @@ +package com.yealch.yealch; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class UsersController { + + private final UserRepository userRepository; + + public UsersController(UserRepository userRepository) { + this.userRepository = userRepository; + } + + @GetMapping("/api/users") + public Iterable getUsers() { + return userRepository.findAll(); + } +} diff --git a/server/src/main/java/com/yealch/yealch/YealchApplication.java b/server/src/main/java/com/yealch/yealch/YealchApplication.java new file mode 100644 index 0000000..6f091ca --- /dev/null +++ b/server/src/main/java/com/yealch/yealch/YealchApplication.java @@ -0,0 +1,38 @@ +package com.yealch.yealch; + +import jakarta.persistence.*; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.CommandLineRunner; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Bean; + +@SpringBootApplication +public class YealchApplication { + + private static final Logger logger = LoggerFactory.getLogger(YealchApplication.class); + + public static void main(String[] args) { + SpringApplication.run(YealchApplication.class, args); + } + + @Bean + public CommandLineRunner demo(UserRepository userRepository, + org.springframework.security.crypto.password.PasswordEncoder passwordEncoder) { + return (args -> { + logger.info("Let's inspect the beans provided by Spring Boot:"); + + var user = new User(); + user.setName("Yealch"); + user.setUsername("yealch"); + user.setPassword(passwordEncoder.encode("password")); + userRepository.save(user); + + logger.info("User saved with ID: {}", user.getId()); + + userRepository.findAll().forEach(u -> logger.info("User: {}", u)); + }); + } + +} diff --git a/server/src/main/java/dev/claas/flat/FlatApplication.java b/server/src/main/java/dev/claas/flat/FlatApplication.java deleted file mode 100644 index 809b23c..0000000 --- a/server/src/main/java/dev/claas/flat/FlatApplication.java +++ /dev/null @@ -1,28 +0,0 @@ -package dev.claas.flat; - -import org.springframework.boot.SpringApplication; -import org.springframework.boot.autoconfigure.SpringBootApplication; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; - -@SpringBootApplication -@RestController -public class FlatApplication { - - public static void main(String[] args) { - SpringApplication.run(FlatApplication.class, args); - } - - // No version in url as there will only be one version ever - @GetMapping("/api/hello") - public String hello(@RequestParam(value = "name", defaultValue = "World") String name) { - return String.format("Hello %s!", name); - } - -} - -// @Entity -// class User { - -// } diff --git a/server/src/main/resources/application.properties b/server/src/main/resources/application.properties index 5963a6a..43cf16d 100644 --- a/server/src/main/resources/application.properties +++ b/server/src/main/resources/application.properties @@ -1 +1,8 @@ -spring.application.name=flat +spring.application.name=yealch +# Enables H2 console to view database tables at http://localhost:8080/h2-console +spring.h2.console.enabled=true + +#TODO: read from environment (file) +# Base64-encoded dev secret for JWT signing. Replace for production. +app.jwt.secret=YWVhbGNoLWRldi1zZWNyZXQtZm9yLWp3dC1hdXRoLXNvLXNoYXJlLXRoaXMtY29tcHV0ZXItMzI= +app.jwt.expiration-minutes=60 \ No newline at end of file diff --git a/server/src/test/java/dev/claas/flat/FlatApplicationTests.java b/server/src/test/java/com/yealch/yealch/YealchApplicationTests.java similarity index 72% rename from server/src/test/java/dev/claas/flat/FlatApplicationTests.java rename to server/src/test/java/com/yealch/yealch/YealchApplicationTests.java index 4dc368d..6814af8 100644 --- a/server/src/test/java/dev/claas/flat/FlatApplicationTests.java +++ b/server/src/test/java/com/yealch/yealch/YealchApplicationTests.java @@ -1,10 +1,10 @@ -package dev.claas.flat; +package com.yealch.yealch; import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; @SpringBootTest -class FlatApplicationTests { +class YealchApplicationTests { @Test void contextLoads() { -- 2.51.2