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 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