diff --git a/backend/pom.xml b/backend/pom.xml
index 9dcd1f0..9e51f98 100644
--- a/backend/pom.xml
+++ b/backend/pom.xml
@@ -52,6 +52,36 @@
org.flywaydb
flyway-core
+
+ org.modelmapper
+ modelmapper
+ 3.2.0
+
+
+ org.springframework.boot
+ spring-boot-starter-security
+
+
+ io.jsonwebtoken
+ jjwt-api
+ 0.12.3
+
+
+ io.jsonwebtoken
+ jjwt-impl
+ 0.12.3
+ runtime
+
+
+ io.jsonwebtoken
+ jjwt-jackson
+ 0.12.3
+ runtime
+
+
+ org.springframework.boot
+ spring-boot-starter-validation
+
diff --git a/backend/src/main/java/com/oskarwiedeweg/cloudwork/BackendApplication.java b/backend/src/main/java/com/oskarwiedeweg/cloudwork/BackendApplication.java
index 9268551..2556f4a 100644
--- a/backend/src/main/java/com/oskarwiedeweg/cloudwork/BackendApplication.java
+++ b/backend/src/main/java/com/oskarwiedeweg/cloudwork/BackendApplication.java
@@ -1,7 +1,9 @@
package com.oskarwiedeweg.cloudwork;
+import org.modelmapper.ModelMapper;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.context.annotation.Bean;
@SpringBootApplication
public class BackendApplication {
@@ -10,4 +12,9 @@ public static void main(String[] args) {
SpringApplication.run(BackendApplication.class, args);
}
+ @Bean
+ public ModelMapper modelMapper() {
+ return new ModelMapper();
+ }
+
}
diff --git a/backend/src/main/java/com/oskarwiedeweg/cloudwork/auth/AppUserDetailsService.java b/backend/src/main/java/com/oskarwiedeweg/cloudwork/auth/AppUserDetailsService.java
new file mode 100644
index 0000000..9a8d0f3
--- /dev/null
+++ b/backend/src/main/java/com/oskarwiedeweg/cloudwork/auth/AppUserDetailsService.java
@@ -0,0 +1,23 @@
+package com.oskarwiedeweg.cloudwork.auth;
+
+import com.oskarwiedeweg.cloudwork.user.UserService;
+import lombok.Data;
+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;
+
+@Data
+@Service
+public class AppUserDetailsService implements UserDetailsService {
+
+ private final UserService userService;
+
+ @Override
+ public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
+ return new UserUserDetails(userService
+ .getUserByName(username)
+ .orElseThrow(() -> new UsernameNotFoundException("User with username '%s' not found!".formatted(username))));
+ }
+
+}
diff --git a/backend/src/main/java/com/oskarwiedeweg/cloudwork/auth/AuthController.java b/backend/src/main/java/com/oskarwiedeweg/cloudwork/auth/AuthController.java
new file mode 100644
index 0000000..4fd9585
--- /dev/null
+++ b/backend/src/main/java/com/oskarwiedeweg/cloudwork/auth/AuthController.java
@@ -0,0 +1,30 @@
+package com.oskarwiedeweg.cloudwork.auth;
+
+import com.oskarwiedeweg.cloudwork.auth.dto.AuthenticationDto;
+import com.oskarwiedeweg.cloudwork.auth.dto.LoginDto;
+import com.oskarwiedeweg.cloudwork.auth.dto.RegisterDto;
+import jakarta.validation.Valid;
+import lombok.Data;
+import org.springframework.http.HttpStatus;
+import org.springframework.web.bind.annotation.*;
+
+@Data
+@RestController
+@RequestMapping("/v1/auth")
+public class AuthController {
+
+ private final AuthService authService;
+
+ @PostMapping("/login")
+ @ResponseStatus(HttpStatus.CREATED)
+ public AuthenticationDto login(@Valid @RequestBody LoginDto body) {
+ return authService.login(body);
+ }
+
+ @PostMapping("/register")
+ @ResponseStatus(HttpStatus.CREATED)
+ public AuthenticationDto register(@Valid @RequestBody RegisterDto body) {
+ return authService.register(body);
+ }
+
+}
diff --git a/backend/src/main/java/com/oskarwiedeweg/cloudwork/auth/AuthService.java b/backend/src/main/java/com/oskarwiedeweg/cloudwork/auth/AuthService.java
new file mode 100644
index 0000000..67f1dd9
--- /dev/null
+++ b/backend/src/main/java/com/oskarwiedeweg/cloudwork/auth/AuthService.java
@@ -0,0 +1,77 @@
+package com.oskarwiedeweg.cloudwork.auth;
+
+import com.oskarwiedeweg.cloudwork.auth.dto.AuthenticationDto;
+import com.oskarwiedeweg.cloudwork.auth.dto.LoginDto;
+import com.oskarwiedeweg.cloudwork.auth.dto.RegisterDto;
+import com.oskarwiedeweg.cloudwork.auth.token.TokenService;
+import com.oskarwiedeweg.cloudwork.exception.DuplicateUserException;
+import com.oskarwiedeweg.cloudwork.user.User;
+import com.oskarwiedeweg.cloudwork.user.UserDto;
+import com.oskarwiedeweg.cloudwork.user.UserService;
+import lombok.Data;
+import org.modelmapper.ModelMapper;
+import org.springframework.http.HttpStatus;
+import org.springframework.security.authentication.AuthenticationManager;
+import org.springframework.security.authentication.BadCredentialsException;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.Authentication;
+import org.springframework.stereotype.Service;
+import org.springframework.web.server.ResponseStatusException;
+
+@Data
+@Service
+public class AuthService {
+
+ private final AuthenticationManager authenticationManager;
+ private final TokenService tokenService;
+ private final ModelMapper modelMapper;
+ private final UserService userService;
+
+ public AuthenticationDto login(LoginDto loginDto) {
+ String username = transformUsername(loginDto.getUsername());
+ UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken(username, loginDto.getPassword());
+
+ Authentication authenticated;
+ try {
+ authenticated = authenticationManager.authenticate(usernamePasswordAuthenticationToken);
+ } catch (BadCredentialsException e) {
+ throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Bad credentials");
+ }
+
+ if (!(authenticated.getPrincipal() instanceof UserUserDetails userDetails)) {
+ throw new RuntimeException("User Details are not expected UserUserDetails!");
+ }
+
+ User user = userDetails.getUser();
+
+ String token = tokenService.generateToken(user);
+
+ return new AuthenticationDto(token, modelMapper.map(user, UserDto.class));
+ }
+
+ public AuthenticationDto register(RegisterDto body) {
+ String username = transformUsername(body.getUsername());
+
+
+ Long userId;
+ try {
+ userId = userService.createUser(username, body.getEmail(), body.getPassword());
+ } catch (DuplicateUserException e) {
+ throw new ResponseStatusException(HttpStatus.CONFLICT, "Duplicate username '%s'".formatted(username));
+ }
+
+ User tempUser = User.builder()
+ .id(userId)
+ .email(body.getEmail())
+ .name(username)
+ .build();
+ String token = tokenService.generateToken(tempUser);
+
+ return new AuthenticationDto(token, modelMapper.map(tempUser, UserDto.class));
+ }
+
+ private String transformUsername(String username) {
+ return username.toLowerCase();
+ }
+
+}
diff --git a/backend/src/main/java/com/oskarwiedeweg/cloudwork/auth/UserUserDetails.java b/backend/src/main/java/com/oskarwiedeweg/cloudwork/auth/UserUserDetails.java
new file mode 100644
index 0000000..7aa0b33
--- /dev/null
+++ b/backend/src/main/java/com/oskarwiedeweg/cloudwork/auth/UserUserDetails.java
@@ -0,0 +1,49 @@
+package com.oskarwiedeweg.cloudwork.auth;
+
+import com.oskarwiedeweg.cloudwork.user.User;
+import lombok.Data;
+import org.springframework.security.core.GrantedAuthority;
+import org.springframework.security.core.userdetails.UserDetails;
+
+import java.util.Collection;
+
+@Data
+public class UserUserDetails implements UserDetails {
+
+ private final User user;
+
+ @Override
+ public Collection extends GrantedAuthority> getAuthorities() {
+ return null;
+ }
+
+ @Override
+ public String getPassword() {
+ return user.getPassword();
+ }
+
+ @Override
+ public String getUsername() {
+ return user.getName();
+ }
+
+ @Override
+ public boolean isAccountNonExpired() {
+ return true;
+ }
+
+ @Override
+ public boolean isAccountNonLocked() {
+ return true;
+ }
+
+ @Override
+ public boolean isCredentialsNonExpired() {
+ return true;
+ }
+
+ @Override
+ public boolean isEnabled() {
+ return true;
+ }
+}
diff --git a/backend/src/main/java/com/oskarwiedeweg/cloudwork/auth/dto/AuthenticationDto.java b/backend/src/main/java/com/oskarwiedeweg/cloudwork/auth/dto/AuthenticationDto.java
new file mode 100644
index 0000000..a42b3fc
--- /dev/null
+++ b/backend/src/main/java/com/oskarwiedeweg/cloudwork/auth/dto/AuthenticationDto.java
@@ -0,0 +1,12 @@
+package com.oskarwiedeweg.cloudwork.auth.dto;
+
+import com.oskarwiedeweg.cloudwork.user.UserDto;
+import lombok.Data;
+
+@Data
+public class AuthenticationDto {
+
+ private final String accessToken;
+ private final UserDto userData;
+
+}
diff --git a/backend/src/main/java/com/oskarwiedeweg/cloudwork/auth/dto/LoginDto.java b/backend/src/main/java/com/oskarwiedeweg/cloudwork/auth/dto/LoginDto.java
new file mode 100644
index 0000000..b45163c
--- /dev/null
+++ b/backend/src/main/java/com/oskarwiedeweg/cloudwork/auth/dto/LoginDto.java
@@ -0,0 +1,15 @@
+package com.oskarwiedeweg.cloudwork.auth.dto;
+
+import jakarta.validation.constraints.NotBlank;
+import lombok.Data;
+
+@Data
+public class LoginDto {
+
+ @NotBlank
+ private final String username;
+
+ @NotBlank
+ private final String password;
+
+}
diff --git a/backend/src/main/java/com/oskarwiedeweg/cloudwork/auth/dto/RegisterDto.java b/backend/src/main/java/com/oskarwiedeweg/cloudwork/auth/dto/RegisterDto.java
new file mode 100644
index 0000000..b1b5815
--- /dev/null
+++ b/backend/src/main/java/com/oskarwiedeweg/cloudwork/auth/dto/RegisterDto.java
@@ -0,0 +1,23 @@
+package com.oskarwiedeweg.cloudwork.auth.dto;
+
+import jakarta.validation.constraints.Email;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Pattern;
+import lombok.Data;
+
+@Data
+public class RegisterDto {
+
+ @NotBlank
+ @Pattern(regexp = "([A-Za-z0-9-._]{3,})")
+ private final String username;
+
+ @Email
+ @NotBlank
+ private final String email;
+
+ @NotBlank
+ @Pattern(regexp = "((?=.*\\d)(?=.*[a-z])(?=.*[A-Z])).{8,}")
+ private final String password;
+
+}
diff --git a/backend/src/main/java/com/oskarwiedeweg/cloudwork/auth/token/JwtAuthenticationToken.java b/backend/src/main/java/com/oskarwiedeweg/cloudwork/auth/token/JwtAuthenticationToken.java
new file mode 100644
index 0000000..ba5b0ea
--- /dev/null
+++ b/backend/src/main/java/com/oskarwiedeweg/cloudwork/auth/token/JwtAuthenticationToken.java
@@ -0,0 +1,28 @@
+package com.oskarwiedeweg.cloudwork.auth.token;
+
+import org.springframework.security.authentication.AbstractAuthenticationToken;
+
+import java.util.Collections;
+
+public class JwtAuthenticationToken extends AbstractAuthenticationToken {
+
+ private final Long userId;
+ private final String jwtToken;
+
+ public JwtAuthenticationToken(Long userId, String jwtToken) {
+ super(Collections.emptyList());
+ this.userId = userId;
+ this.jwtToken = jwtToken;
+ setAuthenticated(true);
+ }
+
+ @Override
+ public Object getCredentials() {
+ return jwtToken;
+ }
+
+ @Override
+ public Long getPrincipal() {
+ return userId;
+ }
+}
diff --git a/backend/src/main/java/com/oskarwiedeweg/cloudwork/auth/token/TokenFilter.java b/backend/src/main/java/com/oskarwiedeweg/cloudwork/auth/token/TokenFilter.java
new file mode 100644
index 0000000..ea4fe79
--- /dev/null
+++ b/backend/src/main/java/com/oskarwiedeweg/cloudwork/auth/token/TokenFilter.java
@@ -0,0 +1,58 @@
+package com.oskarwiedeweg.cloudwork.auth.token;
+
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import lombok.Data;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.HttpStatus;
+import org.springframework.security.core.context.SecurityContext;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.stereotype.Component;
+import org.springframework.web.filter.OncePerRequestFilter;
+import org.springframework.web.server.ResponseStatusException;
+
+import java.io.IOException;
+
+@Data
+@Slf4j
+@Component
+public class TokenFilter extends OncePerRequestFilter {
+
+ public static final String BEARER_PREFIX = "Bearer ";
+
+ private final TokenService tokenService;
+
+ @Override
+ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
+ String authorizationHeader = request.getHeader("Authorization");
+ if (authorizationHeader == null) {
+ filterChain.doFilter(request, response);
+ return;
+ }
+ if (!authorizationHeader.startsWith(BEARER_PREFIX)) {
+ log.debug("Authorization header not prefixed with '{}'", BEARER_PREFIX);
+ filterChain.doFilter(request, response);
+ return;
+ }
+
+ String token = authorizationHeader.substring(BEARER_PREFIX.length());
+ if (!tokenService.isTokenValid(token)) {
+ log.debug("Invalid jwt token '{}'", token);
+ throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Token invalid or expired.");
+ }
+
+ Long userId = tokenService.getUserId(token);
+ JwtAuthenticationToken authentication = new JwtAuthenticationToken(userId, token);
+
+ SecurityContext context = SecurityContextHolder.createEmptyContext();
+ context.setAuthentication(authentication);
+ SecurityContextHolder.setContext(context);
+
+ log.debug("Token valid. Set authentication.");
+
+ filterChain.doFilter(request, response);
+ }
+
+}
diff --git a/backend/src/main/java/com/oskarwiedeweg/cloudwork/auth/token/TokenService.java b/backend/src/main/java/com/oskarwiedeweg/cloudwork/auth/token/TokenService.java
new file mode 100644
index 0000000..4c1c187
--- /dev/null
+++ b/backend/src/main/java/com/oskarwiedeweg/cloudwork/auth/token/TokenService.java
@@ -0,0 +1,71 @@
+package com.oskarwiedeweg.cloudwork.auth.token;
+
+import com.oskarwiedeweg.cloudwork.user.User;
+import io.jsonwebtoken.Claims;
+import io.jsonwebtoken.Jws;
+import io.jsonwebtoken.JwtException;
+import io.jsonwebtoken.Jwts;
+import io.jsonwebtoken.security.Keys;
+import lombok.Data;
+import lombok.SneakyThrows;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+
+import javax.crypto.SecretKey;
+import java.nio.charset.StandardCharsets;
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.Date;
+
+@Data
+@Service
+public class TokenService {
+
+ @Value("${jwt.key}")
+ private String jwtKey;
+
+ @SneakyThrows
+ public String generateToken(User user) {
+ Instant now = Instant.now();
+ Date from = Date.from(now);
+ Date exp = Date.from(now.plus(30, ChronoUnit.DAYS));
+
+ SecretKey secretKey = generateSecretKey();
+
+ return Jwts.builder()
+ .subject(user.getId().toString())
+ .claim("username", user.getName())
+ .claim("email", user.getEmail())
+ .expiration(exp)
+ .issuedAt(from)
+ .signWith(secretKey)
+ .compact();
+ }
+
+ private SecretKey generateSecretKey() {
+ return Keys.hmacShaKeyFor(jwtKey.getBytes(StandardCharsets.UTF_8));
+ }
+
+ public boolean isTokenValid(String token) {
+ try {
+ Jwts.parser()
+ .verifyWith(generateSecretKey())
+ .build()
+ .parseSignedClaims(token);
+ return true;
+ } catch (JwtException e) {
+ return false;
+ }
+ }
+
+ public Long getUserId(String token) {
+ Jws claimsJws = Jwts.parser()
+ .verifyWith(generateSecretKey())
+ .build()
+ .parseSignedClaims(token);
+ String subject = claimsJws.getPayload().getSubject();
+ return Long.parseLong(subject);
+ }
+
+
+}
diff --git a/backend/src/main/java/com/oskarwiedeweg/cloudwork/config/SecurityConfig.java b/backend/src/main/java/com/oskarwiedeweg/cloudwork/config/SecurityConfig.java
new file mode 100644
index 0000000..517d0d7
--- /dev/null
+++ b/backend/src/main/java/com/oskarwiedeweg/cloudwork/config/SecurityConfig.java
@@ -0,0 +1,53 @@
+package com.oskarwiedeweg.cloudwork.config;
+
+import com.oskarwiedeweg.cloudwork.auth.token.TokenFilter;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.security.authentication.AuthenticationManager;
+import org.springframework.security.authentication.ProviderManager;
+import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
+import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
+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.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
+@EnableMethodSecurity
+public class SecurityConfig {
+
+ @Bean
+ public SecurityFilterChain securityFilterChain(HttpSecurity http, TokenFilter tokenFilter) throws Exception {
+ http.formLogin(AbstractHttpConfigurer::disable);
+ http.csrf(AbstractHttpConfigurer::disable);
+ http.cors(AbstractHttpConfigurer::disable);
+
+ http.authorizeHttpRequests(matcher ->
+ matcher.anyRequest().permitAll());
+
+ http.addFilterBefore(tokenFilter, UsernamePasswordAuthenticationFilter.class);
+
+ return http.build();
+ }
+
+
+ @Bean
+ public PasswordEncoder passwordEncoder() {
+ return new BCryptPasswordEncoder();
+ }
+
+ @Bean
+ public AuthenticationManager authenticationManager(UserDetailsService userDetailsService, PasswordEncoder passwordEncoder) {
+ DaoAuthenticationProvider authenticationProvider = new DaoAuthenticationProvider();
+ authenticationProvider.setUserDetailsService(userDetailsService);
+ authenticationProvider.setPasswordEncoder(passwordEncoder);
+
+ return new ProviderManager(authenticationProvider);
+ }
+
+}
diff --git a/backend/src/main/java/com/oskarwiedeweg/cloudwork/error/GlobalErrorHandler.java b/backend/src/main/java/com/oskarwiedeweg/cloudwork/error/GlobalErrorHandler.java
index 35904ee..49d8efb 100644
--- a/backend/src/main/java/com/oskarwiedeweg/cloudwork/error/GlobalErrorHandler.java
+++ b/backend/src/main/java/com/oskarwiedeweg/cloudwork/error/GlobalErrorHandler.java
@@ -18,6 +18,7 @@ public class GlobalErrorHandler {
@ExceptionHandler(Throwable.class)
public ResponseEntity handleError(Throwable throwable) {
if (!(throwable instanceof ErrorResponse errorResponse)) {
+ System.out.println(throwable);
return construct(HttpStatus.INTERNAL_SERVER_ERROR, HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase());
}
return construct(errorResponse.getStatusCode(),
@@ -26,7 +27,7 @@ public ResponseEntity handleError(Throwable throwable) {
@ExceptionHandler(ResponseStatusException.class)
public ResponseEntity handleError(ResponseStatusException throwable) {
- return construct(throwable.getStatusCode(), throwable.getMessage());
+ return construct(throwable.getStatusCode(), throwable.getReason());
}
diff --git a/backend/src/main/java/com/oskarwiedeweg/cloudwork/exception/DuplicateUserException.java b/backend/src/main/java/com/oskarwiedeweg/cloudwork/exception/DuplicateUserException.java
new file mode 100644
index 0000000..2de5d72
--- /dev/null
+++ b/backend/src/main/java/com/oskarwiedeweg/cloudwork/exception/DuplicateUserException.java
@@ -0,0 +1,4 @@
+package com.oskarwiedeweg.cloudwork.exception;
+
+public class DuplicateUserException extends Exception {
+}
diff --git a/backend/src/main/java/com/oskarwiedeweg/cloudwork/feed/FeedController.java b/backend/src/main/java/com/oskarwiedeweg/cloudwork/feed/FeedController.java
new file mode 100644
index 0000000..a744fd5
--- /dev/null
+++ b/backend/src/main/java/com/oskarwiedeweg/cloudwork/feed/FeedController.java
@@ -0,0 +1,17 @@
+package com.oskarwiedeweg.cloudwork.feed;
+
+import com.oskarwiedeweg.cloudwork.feed.dto.FeedDto;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@RequestMapping("/v1/feed")
+public record FeedController(FeedService feedService) {
+
+ @GetMapping
+ public FeedDto getFeed() {
+ return feedService.getFeed();
+ }
+
+}
diff --git a/backend/src/main/java/com/oskarwiedeweg/cloudwork/feed/FeedService.java b/backend/src/main/java/com/oskarwiedeweg/cloudwork/feed/FeedService.java
new file mode 100644
index 0000000..6003d91
--- /dev/null
+++ b/backend/src/main/java/com/oskarwiedeweg/cloudwork/feed/FeedService.java
@@ -0,0 +1,32 @@
+package com.oskarwiedeweg.cloudwork.feed;
+
+import com.oskarwiedeweg.cloudwork.feed.dto.FeedDto;
+import com.oskarwiedeweg.cloudwork.feed.dto.PostDto;
+import com.oskarwiedeweg.cloudwork.feed.post.PostDao;
+import com.oskarwiedeweg.cloudwork.user.UserDto;
+import lombok.Data;
+import org.modelmapper.ModelMapper;
+import org.springframework.stereotype.Service;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+@Data
+@Service
+public class FeedService {
+
+ private final PostDao postDao;
+ private final ModelMapper modelMapper;
+
+ public FeedDto getFeed() {
+ Map users = new HashMap<>();
+ List posts = postDao.getPosts().stream()
+ .peek(post -> users.put(post.getUser().getId(), modelMapper.map(post.getUser(), UserDto.class)))
+ .map(post -> modelMapper.map(post, PostDto.class))
+ .toList();
+
+ return new FeedDto(posts, users);
+ }
+
+}
diff --git a/backend/src/main/java/com/oskarwiedeweg/cloudwork/feed/dto/FeedDto.java b/backend/src/main/java/com/oskarwiedeweg/cloudwork/feed/dto/FeedDto.java
new file mode 100644
index 0000000..105beaa
--- /dev/null
+++ b/backend/src/main/java/com/oskarwiedeweg/cloudwork/feed/dto/FeedDto.java
@@ -0,0 +1,15 @@
+package com.oskarwiedeweg.cloudwork.feed.dto;
+
+import com.oskarwiedeweg.cloudwork.user.UserDto;
+import lombok.Data;
+
+import java.util.List;
+import java.util.Map;
+
+@Data
+public class FeedDto {
+
+ private final List posts;
+ private final Map users;
+
+}
diff --git a/backend/src/main/java/com/oskarwiedeweg/cloudwork/feed/dto/PostDto.java b/backend/src/main/java/com/oskarwiedeweg/cloudwork/feed/dto/PostDto.java
new file mode 100644
index 0000000..72732d0
--- /dev/null
+++ b/backend/src/main/java/com/oskarwiedeweg/cloudwork/feed/dto/PostDto.java
@@ -0,0 +1,14 @@
+package com.oskarwiedeweg.cloudwork.feed.dto;
+
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+@Data
+public class PostDto {
+ private Long id;
+ private String title;
+ private String description;
+ private LocalDateTime timestamp;
+
+}
diff --git a/backend/src/main/java/com/oskarwiedeweg/cloudwork/feed/post/Post.java b/backend/src/main/java/com/oskarwiedeweg/cloudwork/feed/post/Post.java
new file mode 100644
index 0000000..73ec405
--- /dev/null
+++ b/backend/src/main/java/com/oskarwiedeweg/cloudwork/feed/post/Post.java
@@ -0,0 +1,16 @@
+package com.oskarwiedeweg.cloudwork.feed.post;
+
+import com.oskarwiedeweg.cloudwork.user.User;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+@Data
+public class Post {
+ private final Long id;
+ private final String title;
+ private final String description;
+ private final LocalDateTime timestamp;
+ private final User user;
+
+}
diff --git a/backend/src/main/java/com/oskarwiedeweg/cloudwork/feed/post/PostDao.java b/backend/src/main/java/com/oskarwiedeweg/cloudwork/feed/post/PostDao.java
new file mode 100644
index 0000000..79f821b
--- /dev/null
+++ b/backend/src/main/java/com/oskarwiedeweg/cloudwork/feed/post/PostDao.java
@@ -0,0 +1,19 @@
+package com.oskarwiedeweg.cloudwork.feed.post;
+
+import lombok.Data;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.stereotype.Repository;
+
+import java.util.List;
+
+@Data
+@Repository
+public class PostDao {
+ private final JdbcTemplate jdbcTemplate;
+ private final PostRowMapper rowMapper;
+
+ public List getPosts() {
+ return jdbcTemplate.query("select posts.*, users.name as user_name from posts inner join users on posts.user_id = users.id", rowMapper);
+ }
+
+}
diff --git a/backend/src/main/java/com/oskarwiedeweg/cloudwork/feed/post/PostRowMapper.java b/backend/src/main/java/com/oskarwiedeweg/cloudwork/feed/post/PostRowMapper.java
new file mode 100644
index 0000000..f727bd3
--- /dev/null
+++ b/backend/src/main/java/com/oskarwiedeweg/cloudwork/feed/post/PostRowMapper.java
@@ -0,0 +1,34 @@
+package com.oskarwiedeweg.cloudwork.feed.post;
+
+import com.oskarwiedeweg.cloudwork.user.User;
+import com.oskarwiedeweg.cloudwork.user.UserRowMapper;
+import org.springframework.jdbc.core.RowMapper;
+import org.springframework.stereotype.Component;
+
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Timestamp;
+import java.util.Calendar;
+import java.util.TimeZone;
+
+@Component
+public class PostRowMapper implements RowMapper {
+
+ public static final Calendar tzUTC = Calendar.getInstance(TimeZone.getTimeZone("UTC"));
+
+ private final RowMapper userPrefixedRowMapper = new UserRowMapper(true);
+
+ @Override
+ public Post mapRow(ResultSet resultSet, int rowNum) throws SQLException {
+ Timestamp publishedAt = resultSet.getTimestamp("published_at");
+
+ return new Post(
+ resultSet.getLong("id"),
+ resultSet.getString("title"),
+ resultSet.getString("description"),
+ publishedAt.toLocalDateTime(),
+ userPrefixedRowMapper.mapRow(resultSet, rowNum)
+ );
+ }
+
+}
diff --git a/backend/src/main/java/com/oskarwiedeweg/cloudwork/user/User.java b/backend/src/main/java/com/oskarwiedeweg/cloudwork/user/User.java
new file mode 100644
index 0000000..add494a
--- /dev/null
+++ b/backend/src/main/java/com/oskarwiedeweg/cloudwork/user/User.java
@@ -0,0 +1,16 @@
+package com.oskarwiedeweg.cloudwork.user;
+
+import lombok.Builder;
+import lombok.Data;
+
+import java.time.LocalDate;
+
+@Data
+@Builder
+public class User {
+ private final Long id;
+ private final String name;
+ private final String email;
+ private final String password;
+ private final LocalDate localDate;
+}
diff --git a/backend/src/main/java/com/oskarwiedeweg/cloudwork/user/UserDao.java b/backend/src/main/java/com/oskarwiedeweg/cloudwork/user/UserDao.java
new file mode 100644
index 0000000..ee18c49
--- /dev/null
+++ b/backend/src/main/java/com/oskarwiedeweg/cloudwork/user/UserDao.java
@@ -0,0 +1,52 @@
+package com.oskarwiedeweg.cloudwork.user;
+
+import com.oskarwiedeweg.cloudwork.exception.DuplicateUserException;
+import lombok.Data;
+import org.springframework.dao.DuplicateKeyException;
+import org.springframework.dao.IncorrectResultSizeDataAccessException;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.jdbc.support.GeneratedKeyHolder;
+import org.springframework.stereotype.Repository;
+
+import java.sql.Date;
+import java.sql.PreparedStatement;
+import java.time.Clock;
+import java.time.LocalDate;
+import java.util.Optional;
+
+@Data
+@Repository
+public class UserDao {
+
+
+ private final JdbcTemplate jdbcTemplate;
+ private final UserRowMapper rowMapper;
+
+ public Long saveUser(String username, String email, String password) throws DuplicateUserException {
+ GeneratedKeyHolder generatedKeyHolder = new GeneratedKeyHolder();
+
+ try {
+ jdbcTemplate.update((con) -> {
+ PreparedStatement preparedStatement = con.prepareStatement("insert into users (name, email, password, created_at) VALUES (?, ?, ?, ?)", PreparedStatement.RETURN_GENERATED_KEYS);
+ preparedStatement.setString(1, username);
+ preparedStatement.setString(2, email);
+ preparedStatement.setString(3, password);
+ preparedStatement.setDate(4, Date.valueOf(LocalDate.now(Clock.systemUTC())));
+ return preparedStatement;
+ }, generatedKeyHolder);
+ } catch (DuplicateKeyException e) {
+ throw new DuplicateUserException();
+ }
+
+ return (Long) generatedKeyHolder.getKeys().get("id");
+ }
+
+ public Optional findUserByName(String username) {
+ try {
+ User user = jdbcTemplate.queryForObject("select * from users where name = ?", rowMapper, username);
+ return Optional.ofNullable(user);
+ } catch (IncorrectResultSizeDataAccessException ok) {
+ return Optional.empty();
+ }
+ }
+}
diff --git a/backend/src/main/java/com/oskarwiedeweg/cloudwork/user/UserDto.java b/backend/src/main/java/com/oskarwiedeweg/cloudwork/user/UserDto.java
new file mode 100644
index 0000000..2fbbe09
--- /dev/null
+++ b/backend/src/main/java/com/oskarwiedeweg/cloudwork/user/UserDto.java
@@ -0,0 +1,11 @@
+package com.oskarwiedeweg.cloudwork.user;
+
+import lombok.Data;
+
+@Data
+public class UserDto {
+
+ private Long id;
+ private String name;
+
+}
diff --git a/backend/src/main/java/com/oskarwiedeweg/cloudwork/user/UserRowMapper.java b/backend/src/main/java/com/oskarwiedeweg/cloudwork/user/UserRowMapper.java
new file mode 100644
index 0000000..b038feb
--- /dev/null
+++ b/backend/src/main/java/com/oskarwiedeweg/cloudwork/user/UserRowMapper.java
@@ -0,0 +1,47 @@
+package com.oskarwiedeweg.cloudwork.user;
+
+import org.springframework.jdbc.core.RowMapper;
+import org.springframework.stereotype.Component;
+
+import java.sql.ResultSet;
+import java.sql.SQLException;
+
+@Component
+public class UserRowMapper implements RowMapper {
+
+ private final boolean prefixed;
+
+ public UserRowMapper() {
+ this(false);
+ }
+
+ public UserRowMapper(boolean prefixed) {
+ this.prefixed = prefixed;
+ }
+
+ @Override
+ public User mapRow(ResultSet resultSet, int rowNum) throws SQLException {
+ return new User(
+ isThere(resultSet, column("id")) ? resultSet.getLong(column("id")) : null,
+ isThere(resultSet, column("name")) ? resultSet.getString(column("name")) : null,
+ isThere(resultSet, column("email")) ? resultSet.getString(column("email")) : null,
+ isThere(resultSet, column("password")) ? resultSet.getString(column("password")) : null,
+ isThere(resultSet, column("created_at")) ? resultSet.getDate(column("created_at")).toLocalDate() : null
+ );
+ }
+
+ private String column(String name) {
+ return (prefixed ? "user_" : "") + name;
+ }
+
+ private boolean isThere(ResultSet rs, String column){
+ try{
+ rs.findColumn(column);
+ return true;
+ } catch (SQLException ignored){
+ }
+
+ return false;
+ }
+
+}
diff --git a/backend/src/main/java/com/oskarwiedeweg/cloudwork/user/UserService.java b/backend/src/main/java/com/oskarwiedeweg/cloudwork/user/UserService.java
new file mode 100644
index 0000000..9f488a4
--- /dev/null
+++ b/backend/src/main/java/com/oskarwiedeweg/cloudwork/user/UserService.java
@@ -0,0 +1,26 @@
+package com.oskarwiedeweg.cloudwork.user;
+
+import com.oskarwiedeweg.cloudwork.exception.DuplicateUserException;
+import lombok.Data;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.stereotype.Service;
+
+import java.util.Optional;
+
+@Data
+@Service
+public class UserService {
+
+ private final UserDao userDao;
+ private final PasswordEncoder passwordEncoder;
+
+ public Long createUser(String name, String email, String password) throws DuplicateUserException {
+ String encoded = passwordEncoder.encode(password);
+ return userDao.saveUser(name, email, encoded);
+ }
+
+ public Optional getUserByName(String username) {
+ return userDao.findUserByName(username);
+ }
+
+}
diff --git a/backend/src/main/resources/application-local.yml b/backend/src/main/resources/application-local.yml
index 160231a..92a7381 100644
--- a/backend/src/main/resources/application-local.yml
+++ b/backend/src/main/resources/application-local.yml
@@ -5,4 +5,6 @@ spring:
password: pw
jpa:
hibernate:
- ddl-auto: create-drop
\ No newline at end of file
+ ddl-auto: create-drop
+jwt:
+ key: "Lo16Oo15M150c13mTlgdJUxj7ZlaBdteZsbkUClLN2I"
\ No newline at end of file
diff --git a/backend/src/main/resources/db/migration/V1_1__create_post_schema.sql b/backend/src/main/resources/db/migration/V1_1__create_post_schema.sql
new file mode 100644
index 0000000..c1e91c2
--- /dev/null
+++ b/backend/src/main/resources/db/migration/V1_1__create_post_schema.sql
@@ -0,0 +1,8 @@
+create table "posts" (
+ id bigserial primary key,
+ user_id bigint not null,
+ title varchar(255) not null,
+ description text,
+ published_at timestamp,
+ foreign key (user_id) references "user"(id)
+)
\ No newline at end of file
diff --git a/backend/src/main/resources/db/migration/V1_2__upade_user_schema.sql b/backend/src/main/resources/db/migration/V1_2__upade_user_schema.sql
new file mode 100644
index 0000000..ba67aa4
--- /dev/null
+++ b/backend/src/main/resources/db/migration/V1_2__upade_user_schema.sql
@@ -0,0 +1,3 @@
+alter table "user"
+ add column created_at date not null default current_date;
+alter table "user" rename to users;
\ No newline at end of file
diff --git a/backend/src/main/resources/db/migration/V1_3__longer_user_fields.sql b/backend/src/main/resources/db/migration/V1_3__longer_user_fields.sql
new file mode 100644
index 0000000..e54a5d8
--- /dev/null
+++ b/backend/src/main/resources/db/migration/V1_3__longer_user_fields.sql
@@ -0,0 +1,3 @@
+alter table users alter column name type varchar(255);
+alter table users alter column password type varchar(255);
+alter table users alter column email type varchar(255);
\ No newline at end of file
diff --git a/backend/src/main/resources/db/migration/V1_4__unique_usernames.sql b/backend/src/main/resources/db/migration/V1_4__unique_usernames.sql
new file mode 100644
index 0000000..6096526
--- /dev/null
+++ b/backend/src/main/resources/db/migration/V1_4__unique_usernames.sql
@@ -0,0 +1 @@
+alter table users add constraint users_name_unique UNIQUE (name);
\ No newline at end of file
diff --git a/backend/src/test/java/com/oskarwiedeweg/cloudwork/auth/AuthServiceTest.java b/backend/src/test/java/com/oskarwiedeweg/cloudwork/auth/AuthServiceTest.java
new file mode 100644
index 0000000..4574f9d
--- /dev/null
+++ b/backend/src/test/java/com/oskarwiedeweg/cloudwork/auth/AuthServiceTest.java
@@ -0,0 +1,115 @@
+package com.oskarwiedeweg.cloudwork.auth;
+
+import com.oskarwiedeweg.cloudwork.auth.dto.AuthenticationDto;
+import com.oskarwiedeweg.cloudwork.auth.dto.LoginDto;
+import com.oskarwiedeweg.cloudwork.auth.token.TokenService;
+import com.oskarwiedeweg.cloudwork.user.User;
+import com.oskarwiedeweg.cloudwork.user.UserDto;
+import com.oskarwiedeweg.cloudwork.user.UserService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.modelmapper.ModelMapper;
+import org.springframework.http.HttpStatus;
+import org.springframework.security.authentication.AuthenticationManager;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.web.server.ResponseStatusException;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.Mockito.*;
+
+class AuthServiceTest {
+
+ private AuthService underTest;
+
+ private AuthenticationManager authenticationManager;
+ private TokenService tokenService;
+ private ModelMapper modelMapper;
+ private UserService userService;
+
+ @BeforeEach
+ public void setup() {
+ authenticationManager = mock(AuthenticationManager.class);
+ tokenService = mock(TokenService.class);
+ modelMapper = mock(ModelMapper.class);
+ userService = mock(UserService.class);
+ underTest = new AuthService(authenticationManager, tokenService, modelMapper, userService);
+ }
+
+ @Test
+ void testSuccessfulLogin() {
+ //given
+ LoginDto loginDto = new LoginDto("test", "test");
+ UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(loginDto.getUsername(), loginDto.getPassword());
+ User user = mock(User.class);
+ UserDto userDto = mock(UserDto.class);
+ UserUserDetails userDetails = mock(UserUserDetails.class);
+ String jwt = "key";
+
+ //when
+ when(userDetails.getUser()).thenReturn(user);
+ when(authenticationManager.authenticate(token))
+ .thenReturn(new UsernamePasswordAuthenticationToken(userDetails, null));
+ when(tokenService.generateToken(user)).thenReturn(jwt);
+ when(modelMapper.map(user, UserDto.class)).thenReturn(userDto);
+
+ // then
+ AuthenticationDto login = underTest.login(loginDto);
+
+ assertEquals(jwt, login.getAccessToken());
+ assertEquals(userDto, login.getUserData());
+
+ verify(authenticationManager).authenticate(token);
+ verify(tokenService).generateToken(user);
+ }
+
+ @Test
+ void testInvalidCredentialsLogin() {
+ //given
+ LoginDto loginDto = new LoginDto("test", "test");
+ UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(loginDto.getUsername(), loginDto.getPassword());
+ User user = mock(User.class);
+ UserDto userDto = mock(UserDto.class);
+ UserUserDetails userDetails = mock(UserUserDetails.class);
+ String jwt = "key";
+
+ //when
+ when(userDetails.getUser()).thenReturn(user);
+ when(authenticationManager.authenticate(token))
+ .thenThrow(new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Bad credentials"));
+ when(tokenService.generateToken(user)).thenReturn(jwt);
+ when(modelMapper.map(user, UserDto.class)).thenReturn(userDto);
+
+ // then
+ ResponseStatusException responseStatusException = assertThrows(ResponseStatusException.class,
+ () -> underTest.login(loginDto));
+
+ assertEquals(responseStatusException.getStatusCode(), HttpStatus.UNAUTHORIZED);
+
+ verify(authenticationManager).authenticate(token);
+ verify(tokenService, never()).generateToken(user);
+ verify(modelMapper, never()).map(user, UserDto.class);
+ }
+
+ @Test
+ void testUnexpectedPrincipalLogin() {
+ //given
+ LoginDto loginDto = new LoginDto("test", "test");
+ UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(loginDto.getUsername(), loginDto.getPassword());
+ User user = mock(User.class);
+ UserDto userDto = mock(UserDto.class);
+ Object userDetails = mock(Object.class);
+ String jwt = "key";
+
+ //when
+ when(authenticationManager.authenticate(token))
+ .thenReturn(new UsernamePasswordAuthenticationToken(userDetails, null));
+ when(tokenService.generateToken(user)).thenReturn(jwt);
+ when(modelMapper.map(user, UserDto.class)).thenReturn(userDto);
+
+ // then
+ RuntimeException runtimeException = assertThrows(RuntimeException.class, () -> underTest.login(loginDto));
+
+ assertEquals(runtimeException.getMessage(), "User Details are not expected UserUserDetails!");
+ }
+}
\ No newline at end of file
diff --git a/backend/src/test/java/com/oskarwiedeweg/cloudwork/user/UserServiceTest.java b/backend/src/test/java/com/oskarwiedeweg/cloudwork/user/UserServiceTest.java
new file mode 100644
index 0000000..3b46743
--- /dev/null
+++ b/backend/src/test/java/com/oskarwiedeweg/cloudwork/user/UserServiceTest.java
@@ -0,0 +1,67 @@
+package com.oskarwiedeweg.cloudwork.user;
+
+import lombok.SneakyThrows;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.security.crypto.password.PasswordEncoder;
+
+import java.util.Optional;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.*;
+
+class UserServiceTest {
+
+ private UserDao userDao;
+ private PasswordEncoder passwordEncoder;
+
+ private UserService underTest;
+
+ @BeforeEach
+ public void setup() {
+ userDao = mock(UserDao.class);
+ passwordEncoder = mock(PasswordEncoder.class);
+ underTest = new UserService(userDao, passwordEncoder);
+ }
+
+ @SneakyThrows
+ @Test
+ void testCreateUser() {
+ //given
+ String name = "test";
+ String email = "test@gmail.com";
+ String password = "test";
+ String encodedPassword = "encoded";
+
+ Long id = 1L;
+
+ //when
+ when(userDao.saveUser(name, email, encodedPassword))
+ .thenReturn(id);
+ when(passwordEncoder.encode(password))
+ .thenReturn(encodedPassword);
+
+ //then
+ Long user = underTest.createUser(name, email, password);
+
+ assertEquals(user, id);
+ verify(userDao).saveUser(name, email, encodedPassword);
+ }
+
+ @Test
+ void testGetUserByName() {
+ //given
+ String username = "test";
+ User user = mock(User.class);
+
+ //when
+ when(userDao.findUserByName(username)).thenReturn(Optional.of(user));
+
+ //then
+ Optional userByName = underTest.getUserByName(username);
+
+ assertTrue(userByName.isPresent());
+ assertEquals(userByName.get(), user);
+ }
+}
\ No newline at end of file