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() {