diff --git a/.gitignore b/.gitignore index c2065bc..b3db3e2 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,5 @@ out/ ### VS Code ### .vscode/ + +application.yml diff --git a/Docker-compose/docker-compose.yml b/Docker-compose/docker-compose.yml new file mode 100644 index 0000000..98984c6 --- /dev/null +++ b/Docker-compose/docker-compose.yml @@ -0,0 +1,21 @@ +services: + app: + image: 'wilmers-social-box:latest' + container_name: app + depends_on: + - db + ports: + - '4000:4000' + environment: + - SPRING_DATASOURCE_URL=jdbc:postgresql://db:5432/mypostgresuser + - SPRING_DATASOURCE_USERNAME=mypostgresuser + - SPRING_DATASOURCE_PASSWORD=mypostgrespassword + - SPRING_JPA_HIBERNATE_DDL_AUTO=update + + db: + image: 'postgres:latest' + container_name: db + environment: + - POSTGRES_USER=mypostgresuser + - POSTGRES_DATABASE=mypostgresuser + - POSTGRES_PASSWORD=mypostgrespassword \ No newline at end of file diff --git a/build.gradle b/build.gradle index fb070c4..cf0f165 100644 --- a/build.gradle +++ b/build.gradle @@ -1,12 +1,17 @@ plugins { id 'java' - id 'org.springframework.boot' version '3.1.4' - id 'io.spring.dependency-management' version '1.1.3' + id 'org.springframework.boot' version '3.4.2' + id 'io.spring.dependency-management' version '1.1.7' } group = 'com.booleanuk' -version = '0.0.1' -sourceCompatibility = '17' +version = '0.0.2' + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } +} configurations { compileOnly { @@ -20,12 +25,25 @@ repositories { dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'io.jsonwebtoken:jjwt-api:0.12.6' + implementation 'io.jsonwebtoken:jjwt-impl:0.12.6' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6' + compileOnly 'org.projectlombok:lombok' developmentOnly 'org.springframework.boot:spring-boot-devtools' + runtimeOnly 'org.postgresql:postgresql' implementation 'org.postgresql:postgresql:42.6.0' annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.security:spring-security-test' + + implementation 'jakarta.validation:jakarta.validation-api:3.1.0' +} + +tasks.named('bootBuildImage') { + builder = 'paketobuildpacks/builder-jammy-base:latest' } tasks.named('test') { diff --git a/src/main/java/com/booleanuk/api/.gitkeep b/src/main/java/com/booleanuk/api/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/java/com/booleanuk/api/Main.java b/src/main/java/com/booleanuk/api/Main.java new file mode 100644 index 0000000..b1cba0a --- /dev/null +++ b/src/main/java/com/booleanuk/api/Main.java @@ -0,0 +1,30 @@ +package com.booleanuk.api; + +import com.booleanuk.api.models.ERole; +import com.booleanuk.api.models.Role; +import com.booleanuk.api.repositories.RoleRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.CommandLineRunner; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class Main implements CommandLineRunner { + @Autowired + private RoleRepository roleRepository; + + public static void main(String[] args) { + SpringApplication.run(Main.class, args); + } + + @Override + public void run(String... args) { + if (!this.roleRepository.existsByName(ERole.ROLE_USER)) { + this.roleRepository.save(new Role(ERole.ROLE_USER)); + } + if (!this.roleRepository.existsByName(ERole.ROLE_ADMIN)) { + this.roleRepository.save(new Role(ERole.ROLE_ADMIN)); + } + } + +} \ No newline at end of file diff --git a/src/main/java/com/booleanuk/api/controllers/AuthController.java b/src/main/java/com/booleanuk/api/controllers/AuthController.java new file mode 100644 index 0000000..f7684c2 --- /dev/null +++ b/src/main/java/com/booleanuk/api/controllers/AuthController.java @@ -0,0 +1,97 @@ +package com.booleanuk.api.controllers; + +import com.booleanuk.api.models.ERole; +import com.booleanuk.api.models.Role; +import com.booleanuk.api.models.User; +import com.booleanuk.api.payload.request.LoginRequest; +import com.booleanuk.api.payload.request.SignupRequest; +import com.booleanuk.api.payload.response.JwtResponse; +import com.booleanuk.api.payload.response.MessageResponse; +import com.booleanuk.api.repositories.RoleRepository; +import com.booleanuk.api.repositories.UserRepository; +import com.booleanuk.api.security.jwt.JwtUtils; +import com.booleanuk.api.security.services.UserDetailsImpl; +import jakarta.validation.Valid; +import org.springframework.beans.factory.annotation.Autowired; +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.context.SecurityContextHolder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.web.bind.annotation.*; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +@CrossOrigin(origins = "*", maxAge = 3600) +@RestController +@RequestMapping("auth") +public class AuthController { + @Autowired + AuthenticationManager authenticationManager; + + @Autowired + UserRepository userRepository; + + @Autowired + RoleRepository roleRepository; + + @Autowired + PasswordEncoder encoder; + + @Autowired + JwtUtils jwtUtils; + + @PostMapping("/signin") + public ResponseEntity authenticateUser(@Valid @RequestBody LoginRequest loginRequest) { + // If using a salt for password use it here + Authentication authentication = authenticationManager + .authenticate(new UsernamePasswordAuthenticationToken(loginRequest.getUsername(), loginRequest.getPassword())); + SecurityContextHolder.getContext().setAuthentication(authentication); + String jwt = jwtUtils.generateJwtToken(authentication); + + UserDetailsImpl userDetails = (UserDetailsImpl) authentication.getPrincipal(); + List roles = userDetails.getAuthorities().stream().map((item) -> item.getAuthority()) + .collect(Collectors.toList()); + return ResponseEntity + .ok(new JwtResponse(jwt, userDetails.getId(), userDetails.getUsername(), userDetails.getEmail(), roles)); + } + + @PostMapping("/signup") + public ResponseEntity registerUser(@Valid @RequestBody SignupRequest signupRequest) { + if (userRepository.existsByUsername(signupRequest.getUsername())) { + return ResponseEntity.badRequest().body(new MessageResponse("Error: Username is already taken")); + } + if (userRepository.existsByEmail(signupRequest.getEmail())) { + return ResponseEntity.badRequest().body(new MessageResponse("Error: Email is already in use!")); + } + // Create a new user add salt here if using one + User user = new User(signupRequest.getUsername(), signupRequest.getEmail(), encoder.encode(signupRequest.getPassword())); + Set strRoles = signupRequest.getRole(); + Set roles = new HashSet<>(); + + if (strRoles == null) { + Role userRole = roleRepository.findByName(ERole.ROLE_USER).orElseThrow(() -> new RuntimeException("Error: Role is not found")); + roles.add(userRole); + } else { + strRoles.forEach((role) -> { + switch (role) { + case "admin": + Role adminRole = roleRepository.findByName(ERole.ROLE_ADMIN).orElseThrow(() -> new RuntimeException("Error: Role is not found")); + roles.add(adminRole); + break; + default: + Role userRole = roleRepository.findByName(ERole.ROLE_USER).orElseThrow(() -> new RuntimeException("Error: Role is not found")); + roles.add(userRole); + break; + } + }); + } + user.setRoles(roles); + userRepository.save(user); + return ResponseEntity.ok((new MessageResponse("User registered successfully"))); + } +} \ No newline at end of file diff --git a/src/main/java/com/booleanuk/api/controllers/FollowerController.java b/src/main/java/com/booleanuk/api/controllers/FollowerController.java new file mode 100644 index 0000000..89bdb92 --- /dev/null +++ b/src/main/java/com/booleanuk/api/controllers/FollowerController.java @@ -0,0 +1,63 @@ +package com.booleanuk.api.controllers; + +import com.booleanuk.api.models.Follower; +import com.booleanuk.api.models.User; +import com.booleanuk.api.payload.response.*; +import com.booleanuk.api.repositories.FollowerRepository; +import com.booleanuk.api.repositories.UserRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("user") +public class FollowerController { + + @Autowired + private FollowerRepository followerRepository; + @Autowired + private UserRepository userRepository; + + + + @DeleteMapping("/unfollow/{id}") + public ResponseEntity> unfollowUser(@PathVariable int id) { + Follower followerToDelete = this.followerRepository.findById(id).orElse(null); + if (followerToDelete == null) { + ErrorResponse error = new ErrorResponse(); + error.set("not found"); + return new ResponseEntity<>(error, HttpStatus.NOT_FOUND); + } + this.followerRepository.delete(followerToDelete); + FollowerResponse followerResponse = new FollowerResponse(); + followerResponse.set(followerToDelete); + return ResponseEntity.ok(followerResponse); + } + + @PostMapping("/follow") + public ResponseEntity> followUser(@RequestBody Follower follow) { + FollowerResponse followerResponse = new FollowerResponse(); + User follower = this.userRepository.findById(follow.getFollower().getId()).orElse(null); + User following = this.userRepository.findById(follow.getFollowing().getId()).orElse(null); + try { + follow.setFollower(follower); + follow.setFollowing(following); + followerResponse.set(this.followerRepository.save(follow)); + } catch (Exception e) { + ErrorResponse error = new ErrorResponse(); + error.set("Bad request"); + return new ResponseEntity<>(error, HttpStatus.BAD_REQUEST); + } + return new ResponseEntity<>(followerResponse, HttpStatus.CREATED); + } + + @GetMapping("/followers/{id}") + public ResponseEntity listAllFollowers(@PathVariable int id) { + FollowerListRespons followerListRespons = new FollowerListRespons(); + followerListRespons.set(this.followerRepository.findAllByFollowingId(id)); + return ResponseEntity.ok(followerListRespons); + } + + +} diff --git a/src/main/java/com/booleanuk/api/controllers/PostController.java b/src/main/java/com/booleanuk/api/controllers/PostController.java new file mode 100644 index 0000000..5e8260e --- /dev/null +++ b/src/main/java/com/booleanuk/api/controllers/PostController.java @@ -0,0 +1,85 @@ +package com.booleanuk.api.controllers; + +import com.booleanuk.api.models.Post; +import com.booleanuk.api.models.User; +import com.booleanuk.api.payload.response.*; +import com.booleanuk.api.repositories.PostRepository; +import com.booleanuk.api.repositories.UserRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDateTime; + +@RestController +@RequestMapping("user") +public class PostController { + + @Autowired + private PostRepository postRepository; + @Autowired + private UserRepository userRepository; + + + + @PostMapping("/post") + public ResponseEntity> createPost(@RequestBody Post post) { + PostResponse postResponse = new PostResponse(); + User user = this.userRepository.findById(post.getUser().getId()).orElse(null); + + try { + post.setUser(user); + postResponse.set(this.postRepository.save(post)); + } catch (Exception e) { + ErrorResponse error = new ErrorResponse(); + error.set("Bad request"); + return new ResponseEntity<>(error, HttpStatus.BAD_REQUEST); + } + return new ResponseEntity<>(postResponse, HttpStatus.CREATED); + } + + @DeleteMapping("/deletepost/{id}") + public ResponseEntity> deletePost(@PathVariable int id) { + Post postToDelete = this.postRepository.findById(id).orElse(null); + if (postToDelete == null) { + ErrorResponse error = new ErrorResponse(); + error.set("not found"); + return new ResponseEntity<>(error, HttpStatus.NOT_FOUND); + } + this.postRepository.delete(postToDelete); + PostResponse postResponse = new PostResponse(); + postResponse.set(postToDelete); + return ResponseEntity.ok(postResponse); + } + + @GetMapping("/posts/{id}") + public ResponseEntity listAllPostsByUser(@PathVariable int id) { + PostListResponse postListResponse = new PostListResponse(); + postListResponse.set(this.postRepository.findAllByUserId(id)); + return ResponseEntity.ok(postListResponse); + } + + @PutMapping("/updatepost/{id}") + public ResponseEntity> updatePost(@PathVariable int id, @RequestBody Post post) { + Post postToUpdate = this.postRepository.findById(id).orElse(null); + if (postToUpdate == null) { + ErrorResponse error = new ErrorResponse(); + error.set("not found"); + return new ResponseEntity<>(error, HttpStatus.NOT_FOUND); + } + postToUpdate.setContent(post.getContent()); + postToUpdate.setUpdatedAt(LocalDateTime.now()); + + try { + postToUpdate = this.postRepository.save(postToUpdate); + } catch (Exception e) { + ErrorResponse error = new ErrorResponse(); + error.set("Bad request"); + return new ResponseEntity<>(error, HttpStatus.BAD_REQUEST); + } + PostResponse postResponse = new PostResponse(); + postResponse.set(postToUpdate); + return new ResponseEntity<>(postResponse, HttpStatus.CREATED); + } +} diff --git a/src/main/java/com/booleanuk/api/controllers/UpvoteController.java b/src/main/java/com/booleanuk/api/controllers/UpvoteController.java new file mode 100644 index 0000000..e0f951b --- /dev/null +++ b/src/main/java/com/booleanuk/api/controllers/UpvoteController.java @@ -0,0 +1,65 @@ +package com.booleanuk.api.controllers; + +import com.booleanuk.api.models.Post; +import com.booleanuk.api.models.Upvote; +import com.booleanuk.api.models.User; +import com.booleanuk.api.payload.response.*; +import com.booleanuk.api.repositories.PostRepository; +import com.booleanuk.api.repositories.UpvoteRepository; +import com.booleanuk.api.repositories.UserRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("user") +public class UpvoteController { + + @Autowired + private UpvoteRepository upvoteRepository; + @Autowired + private UserRepository userRepository; + @Autowired + private PostRepository postRepository; + + + @PostMapping("/upvote") + public ResponseEntity> createAnUpvote(@RequestBody Upvote upvote) { + UpvoteResponse upvoteResponse = new UpvoteResponse(); + User user = this.userRepository.findById(upvote.getUser().getId()).orElse(null); + Post post = this.postRepository.findById(upvote.getPost().getId()).orElse(null); + + try { + upvote.setUser(user); + upvote.setPost(post); + upvoteResponse.set(this.upvoteRepository.save(upvote)); + } catch (Exception e) { + ErrorResponse error = new ErrorResponse(); + error.set("Bad request"); + return new ResponseEntity<>(error, HttpStatus.BAD_REQUEST); + } + return new ResponseEntity<>(upvoteResponse, HttpStatus.CREATED); + } + + @DeleteMapping("/deleteupvote/{id}") + public ResponseEntity> deleteUpvote(@PathVariable int id) { + Upvote upvoteToDelete = this.upvoteRepository.findById(id).orElse(null); + if (upvoteToDelete == null) { + ErrorResponse error = new ErrorResponse(); + error.set("not found"); + return new ResponseEntity<>(error, HttpStatus.NOT_FOUND); + } + this.upvoteRepository.delete(upvoteToDelete); + UpvoteResponse upvoteResponse = new UpvoteResponse(); + upvoteResponse.set(upvoteToDelete); + return ResponseEntity.ok(upvoteResponse); + } + + @GetMapping("/upvotes/{id}") + public ResponseEntity listAllUserThatUpvoted(@PathVariable int id) { + UpvoteListResponse upvoteListResponse = new UpvoteListResponse(); + upvoteListResponse.set(this.upvoteRepository.findAllByUserId(id)); + return ResponseEntity.ok(upvoteListResponse); + } +} diff --git a/src/main/java/com/booleanuk/api/models/ERole.java b/src/main/java/com/booleanuk/api/models/ERole.java new file mode 100644 index 0000000..c115e33 --- /dev/null +++ b/src/main/java/com/booleanuk/api/models/ERole.java @@ -0,0 +1,6 @@ +package com.booleanuk.api.models; + +public enum ERole { + ROLE_USER, + ROLE_ADMIN +} diff --git a/src/main/java/com/booleanuk/api/models/Follower.java b/src/main/java/com/booleanuk/api/models/Follower.java new file mode 100644 index 0000000..9568b2f --- /dev/null +++ b/src/main/java/com/booleanuk/api/models/Follower.java @@ -0,0 +1,47 @@ +package com.booleanuk.api.models; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; + +@Getter +@Setter +@AllArgsConstructor +@Entity +@Table(name = "followers") +public class Follower { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private int id; + + @ManyToOne + private User follower; + + @ManyToOne + private User following; + + @Column + private LocalDateTime createdAt; + + public Follower(User follower, User following){ + this.follower = follower; + this.following = following; + createdAt = LocalDateTime.now(); + } + + public Follower(){ + createdAt = LocalDateTime.now(); + } + + public Follower(int id){ + this.id = id; + createdAt = LocalDateTime.now(); + } + + +} diff --git a/src/main/java/com/booleanuk/api/models/Post.java b/src/main/java/com/booleanuk/api/models/Post.java new file mode 100644 index 0000000..db76b9f --- /dev/null +++ b/src/main/java/com/booleanuk/api/models/Post.java @@ -0,0 +1,58 @@ +package com.booleanuk.api.models; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; +import java.util.List; + +@Getter +@Setter +@AllArgsConstructor +@Entity +@Table(name = "posts") +@JsonIgnoreProperties({"upvotes"}) +public class Post { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private int id; + + @ManyToOne + private User user; + + @Column + private String content; + +// @JsonIgnore +// @ManyToOne(cascade = CascadeType.ALL) +// private Upvote upvotes; + + @Column + private LocalDateTime createdAt; + + @Column + private LocalDateTime updatedAt; + + public Post(User user, String content){ + this.user = user; + this.content = content; + this.createdAt = LocalDateTime.now(); + this.updatedAt = LocalDateTime.now(); + } + + public Post(){ + this.createdAt = LocalDateTime.now(); + this.updatedAt = LocalDateTime.now(); + } + + public Post(int id){ + this.id = id; + this.createdAt = LocalDateTime.now(); + this.updatedAt = LocalDateTime.now(); + } +} diff --git a/src/main/java/com/booleanuk/api/models/Role.java b/src/main/java/com/booleanuk/api/models/Role.java new file mode 100644 index 0000000..b6d96b3 --- /dev/null +++ b/src/main/java/com/booleanuk/api/models/Role.java @@ -0,0 +1,23 @@ +package com.booleanuk.api.models; + +import jakarta.persistence.*; +import lombok.Data; +import lombok.NoArgsConstructor; + +@NoArgsConstructor +@Data +@Entity +@Table(name = "roles") +public class Role { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private int id; + + @Enumerated(EnumType.STRING) + @Column(length = 20) + private ERole name; + + public Role(ERole name) { + this.name = name; + } +} diff --git a/src/main/java/com/booleanuk/api/models/Upvote.java b/src/main/java/com/booleanuk/api/models/Upvote.java new file mode 100644 index 0000000..382b64e --- /dev/null +++ b/src/main/java/com/booleanuk/api/models/Upvote.java @@ -0,0 +1,45 @@ +package com.booleanuk.api.models; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; + +@Getter +@Setter +@AllArgsConstructor +@Entity +@Table(name = "upvotes") +public class Upvote { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private int id; + + @ManyToOne + private User user; + + @ManyToOne + private Post post; + + @Column + private LocalDateTime createdAt; + + public Upvote(User user, Post post){ + this.user = user; + this.post = post; + createdAt = LocalDateTime.now(); + } + + public Upvote(){ + createdAt = LocalDateTime.now(); + } + + public Upvote(int id){ + this.id = id; + createdAt = LocalDateTime.now(); + } +} diff --git a/src/main/java/com/booleanuk/api/models/User.java b/src/main/java/com/booleanuk/api/models/User.java new file mode 100644 index 0000000..fad7dad --- /dev/null +++ b/src/main/java/com/booleanuk/api/models/User.java @@ -0,0 +1,78 @@ +package com.booleanuk.api.models; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import jakarta.persistence.*; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + + +import java.time.LocalDateTime; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +@JsonIgnoreProperties({"password", "roles", "followers", "following", "posts"}) +@Data +@Entity +@Table(name = "users", + uniqueConstraints = { + @UniqueConstraint(columnNames = "username"), + @UniqueConstraint(columnNames = "email") + }) +public class User { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private int id; + + @NotBlank + @Size(max = 20) + private String username; + + @NotBlank + @Size(max = 50) + @Email + private String email; + + @NotBlank + @Size(max = 120) + private String password; + + @ManyToMany(fetch = FetchType.LAZY) + @JoinTable(name = "user_roles", joinColumns = @JoinColumn(name = "user_id"), inverseJoinColumns = @JoinColumn(name = "role_id")) + private Set roles = new HashSet<>(); + +// @JsonIgnore +// @ManyToOne(cascade = CascadeType.ALL) +// private User follows; +// +// @JsonIgnore +// @ManyToOne(cascade = CascadeType.ALL) +// private Post posts; + + @Column + private LocalDateTime createdAt; + + @Column + private LocalDateTime updatedAt; + + public User(String username, String email, String password) { + this.username = username; + this.email = email; + this.password = password; + this.createdAt = LocalDateTime.now(); + this.updatedAt = LocalDateTime.now(); + } + + public User(){ + this.createdAt = LocalDateTime.now(); + this.updatedAt = LocalDateTime.now(); + } + + public User(int id){ + this.id = id; + this.createdAt = LocalDateTime.now(); + this.updatedAt = LocalDateTime.now(); + } +} \ No newline at end of file diff --git a/src/main/java/com/booleanuk/api/payload/request/LoginRequest.java b/src/main/java/com/booleanuk/api/payload/request/LoginRequest.java new file mode 100644 index 0000000..bb4c3a5 --- /dev/null +++ b/src/main/java/com/booleanuk/api/payload/request/LoginRequest.java @@ -0,0 +1,15 @@ +package com.booleanuk.api.payload.request; + +import jakarta.validation.constraints.NotBlank; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class LoginRequest { + @NotBlank + private String username; + + @NotBlank + private String password; +} diff --git a/src/main/java/com/booleanuk/api/payload/request/SignupRequest.java b/src/main/java/com/booleanuk/api/payload/request/SignupRequest.java new file mode 100644 index 0000000..83720cb --- /dev/null +++ b/src/main/java/com/booleanuk/api/payload/request/SignupRequest.java @@ -0,0 +1,29 @@ +package com.booleanuk.api.payload.request; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Getter; +import lombok.Setter; + +import java.util.Set; + +@Getter +@Setter +public class SignupRequest { + @NotBlank + @Size(min = 3, max = 20) + private String username; + + @NotBlank + @Size(max = 50) + @Email + private String email; + + private Set role; + + @NotBlank + @Size(min = 6, max = 40) + private String password; + +} diff --git a/src/main/java/com/booleanuk/api/payload/response/ErrorResponse.java b/src/main/java/com/booleanuk/api/payload/response/ErrorResponse.java new file mode 100644 index 0000000..d58c282 --- /dev/null +++ b/src/main/java/com/booleanuk/api/payload/response/ErrorResponse.java @@ -0,0 +1,19 @@ +package com.booleanuk.api.payload.response; + +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.HashMap; +import java.util.Map; + +@Getter +@NoArgsConstructor +public class ErrorResponse extends Response> { + public void set(String message) { + this.status = "error"; + + Map reply = new HashMap<>(); + reply.put("message", message); + this.data = reply; + } +} diff --git a/src/main/java/com/booleanuk/api/payload/response/FollowerListRespons.java b/src/main/java/com/booleanuk/api/payload/response/FollowerListRespons.java new file mode 100644 index 0000000..560023d --- /dev/null +++ b/src/main/java/com/booleanuk/api/payload/response/FollowerListRespons.java @@ -0,0 +1,8 @@ +package com.booleanuk.api.payload.response; + +import com.booleanuk.api.models.Follower; + +import java.util.List; + +public class FollowerListRespons extends Response>{ +} diff --git a/src/main/java/com/booleanuk/api/payload/response/FollowerResponse.java b/src/main/java/com/booleanuk/api/payload/response/FollowerResponse.java new file mode 100644 index 0000000..cc5a8af --- /dev/null +++ b/src/main/java/com/booleanuk/api/payload/response/FollowerResponse.java @@ -0,0 +1,6 @@ +package com.booleanuk.api.payload.response; + +import com.booleanuk.api.models.Follower; + +public class FollowerResponse extends Response{ +} diff --git a/src/main/java/com/booleanuk/api/payload/response/JwtResponse.java b/src/main/java/com/booleanuk/api/payload/response/JwtResponse.java new file mode 100644 index 0000000..b596fb1 --- /dev/null +++ b/src/main/java/com/booleanuk/api/payload/response/JwtResponse.java @@ -0,0 +1,25 @@ +package com.booleanuk.api.payload.response; + +import lombok.Getter; +import lombok.Setter; + +import java.util.List; + +@Getter +@Setter +public class JwtResponse { + private String token; + private String type = "Bearer"; + private int id; + private String username; + private String email; + private List roles; + + public JwtResponse(String token, int id, String username, String email, List roles) { + this.token = token; + this.id = id; + this.username = username; + this.email = email; + this.roles = roles; + } +} diff --git a/src/main/java/com/booleanuk/api/payload/response/MessageResponse.java b/src/main/java/com/booleanuk/api/payload/response/MessageResponse.java new file mode 100644 index 0000000..d001efc --- /dev/null +++ b/src/main/java/com/booleanuk/api/payload/response/MessageResponse.java @@ -0,0 +1,14 @@ +package com.booleanuk.api.payload.response; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class MessageResponse { + private String message; + + public MessageResponse(String message) { + this.message = message; + } +} diff --git a/src/main/java/com/booleanuk/api/payload/response/PostListResponse.java b/src/main/java/com/booleanuk/api/payload/response/PostListResponse.java new file mode 100644 index 0000000..be6a8a3 --- /dev/null +++ b/src/main/java/com/booleanuk/api/payload/response/PostListResponse.java @@ -0,0 +1,8 @@ +package com.booleanuk.api.payload.response; + +import com.booleanuk.api.models.Post; + +import java.util.List; + +public class PostListResponse extends Response>{ +} diff --git a/src/main/java/com/booleanuk/api/payload/response/PostResponse.java b/src/main/java/com/booleanuk/api/payload/response/PostResponse.java new file mode 100644 index 0000000..92118fb --- /dev/null +++ b/src/main/java/com/booleanuk/api/payload/response/PostResponse.java @@ -0,0 +1,6 @@ +package com.booleanuk.api.payload.response; + +import com.booleanuk.api.models.Post; + +public class PostResponse extends Response{ +} diff --git a/src/main/java/com/booleanuk/api/payload/response/Response.java b/src/main/java/com/booleanuk/api/payload/response/Response.java new file mode 100644 index 0000000..7acb67f --- /dev/null +++ b/src/main/java/com/booleanuk/api/payload/response/Response.java @@ -0,0 +1,14 @@ +package com.booleanuk.api.payload.response; + +import lombok.Getter; + +@Getter +public class Response { + protected String status; + protected T data; + + public void set(T data) { + this.status = "success"; + this.data = data; + } +} diff --git a/src/main/java/com/booleanuk/api/payload/response/UpvoteListResponse.java b/src/main/java/com/booleanuk/api/payload/response/UpvoteListResponse.java new file mode 100644 index 0000000..81a184e --- /dev/null +++ b/src/main/java/com/booleanuk/api/payload/response/UpvoteListResponse.java @@ -0,0 +1,8 @@ +package com.booleanuk.api.payload.response; + +import com.booleanuk.api.models.Upvote; + +import java.util.List; + +public class UpvoteListResponse extends Response> { +} diff --git a/src/main/java/com/booleanuk/api/payload/response/UpvoteResponse.java b/src/main/java/com/booleanuk/api/payload/response/UpvoteResponse.java new file mode 100644 index 0000000..798ba3a --- /dev/null +++ b/src/main/java/com/booleanuk/api/payload/response/UpvoteResponse.java @@ -0,0 +1,6 @@ +package com.booleanuk.api.payload.response; + +import com.booleanuk.api.models.Upvote; + +public class UpvoteResponse extends Response{ +} diff --git a/src/main/java/com/booleanuk/api/repositories/FollowerRepository.java b/src/main/java/com/booleanuk/api/repositories/FollowerRepository.java new file mode 100644 index 0000000..44a1961 --- /dev/null +++ b/src/main/java/com/booleanuk/api/repositories/FollowerRepository.java @@ -0,0 +1,10 @@ +package com.booleanuk.api.repositories; + +import com.booleanuk.api.models.Follower; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface FollowerRepository extends JpaRepository { + List findAllByFollowingId(int followingId); +} diff --git a/src/main/java/com/booleanuk/api/repositories/PostRepository.java b/src/main/java/com/booleanuk/api/repositories/PostRepository.java new file mode 100644 index 0000000..2b4b89e --- /dev/null +++ b/src/main/java/com/booleanuk/api/repositories/PostRepository.java @@ -0,0 +1,11 @@ +package com.booleanuk.api.repositories; + + +import com.booleanuk.api.models.Post; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface PostRepository extends JpaRepository { + List findAllByUserId(int userId); +} diff --git a/src/main/java/com/booleanuk/api/repositories/RoleRepository.java b/src/main/java/com/booleanuk/api/repositories/RoleRepository.java new file mode 100644 index 0000000..d1eb939 --- /dev/null +++ b/src/main/java/com/booleanuk/api/repositories/RoleRepository.java @@ -0,0 +1,13 @@ +package com.booleanuk.api.repositories; + +import com.booleanuk.api.models.ERole; +import com.booleanuk.api.models.Role; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface RoleRepository extends JpaRepository { + Optional findByName(ERole name); + + boolean existsByName(ERole name); +} diff --git a/src/main/java/com/booleanuk/api/repositories/UpvoteRepository.java b/src/main/java/com/booleanuk/api/repositories/UpvoteRepository.java new file mode 100644 index 0000000..5f7543b --- /dev/null +++ b/src/main/java/com/booleanuk/api/repositories/UpvoteRepository.java @@ -0,0 +1,11 @@ +package com.booleanuk.api.repositories; + + +import com.booleanuk.api.models.Upvote; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface UpvoteRepository extends JpaRepository { + List findAllByUserId(int userId); +} diff --git a/src/main/java/com/booleanuk/api/repositories/UserRepository.java b/src/main/java/com/booleanuk/api/repositories/UserRepository.java new file mode 100644 index 0000000..ff8826b --- /dev/null +++ b/src/main/java/com/booleanuk/api/repositories/UserRepository.java @@ -0,0 +1,15 @@ +package com.booleanuk.api.repositories; + +import com.booleanuk.api.models.User; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface UserRepository extends JpaRepository { + Optional findByUsername(String username); + + Boolean existsByUsername(String username); + Boolean existsByEmail(String email); +} diff --git a/src/main/java/com/booleanuk/api/security/WebSecurityConfig.java b/src/main/java/com/booleanuk/api/security/WebSecurityConfig.java new file mode 100644 index 0000000..a251db5 --- /dev/null +++ b/src/main/java/com/booleanuk/api/security/WebSecurityConfig.java @@ -0,0 +1,70 @@ +package com.booleanuk.api.security; + +import com.booleanuk.api.security.jwt.AuthEntryPointJwt; +import com.booleanuk.api.security.jwt.AuthTokenFilter; +import com.booleanuk.api.security.services.UserDetailsServiceImpl; +import org.springframework.beans.factory.annotation.Autowired; +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.annotation.web.configuration.EnableWebSecurity; +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 +@EnableWebSecurity +public class WebSecurityConfig { + @Autowired + private UserDetailsServiceImpl userDetailsService; + + @Autowired + private AuthEntryPointJwt unauthorizedHandler; + + @Bean + public AuthTokenFilter authenticationJwtTokenFilter() { + return new AuthTokenFilter(); + } + + @Bean + public DaoAuthenticationProvider authenticationProvider() { + DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider(); + + authProvider.setUserDetailsService(userDetailsService); + authProvider.setPasswordEncoder(passwordEncoder()); + + return authProvider; + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + .csrf((csrf) -> csrf.disable()) + .exceptionHandling((exception) -> exception.authenticationEntryPoint(unauthorizedHandler)) + .sessionManagement((session) -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests((requests) -> requests + .requestMatchers("/auth/**").permitAll() + .requestMatchers("/user").authenticated() + .requestMatchers("/user/**").hasRole("USER") + + ); + http.authenticationProvider(authenticationProvider()); + http.addFilterBefore(authenticationJwtTokenFilter(), UsernamePasswordAuthenticationFilter.class); + return http.build(); + } + + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception { + return authenticationConfiguration.getAuthenticationManager(); + } +} diff --git a/src/main/java/com/booleanuk/api/security/jwt/AuthEntryPointJwt.java b/src/main/java/com/booleanuk/api/security/jwt/AuthEntryPointJwt.java new file mode 100644 index 0000000..e7b3fb4 --- /dev/null +++ b/src/main/java/com/booleanuk/api/security/jwt/AuthEntryPointJwt.java @@ -0,0 +1,24 @@ +package com.booleanuk.api.security.jwt; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +public class AuthEntryPointJwt implements AuthenticationEntryPoint { + private static final Logger logger = LoggerFactory.getLogger(AuthEntryPointJwt.class); + + @Override + public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException { + logger.error("Unauthorized error: {}", authException.getMessage()); + response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Error: Unauthorized"); + } + +} diff --git a/src/main/java/com/booleanuk/api/security/jwt/AuthTokenFilter.java b/src/main/java/com/booleanuk/api/security/jwt/AuthTokenFilter.java new file mode 100644 index 0000000..674173d --- /dev/null +++ b/src/main/java/com/booleanuk/api/security/jwt/AuthTokenFilter.java @@ -0,0 +1,54 @@ +package com.booleanuk.api.security.jwt; + +import com.booleanuk.api.security.services.UserDetailsServiceImpl; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +public class AuthTokenFilter extends OncePerRequestFilter { + @Autowired + private JwtUtils jwtUtils; + + @Autowired + private UserDetailsServiceImpl userDetailsService; + + private static final Logger logger = LoggerFactory.getLogger(AuthTokenFilter.class); + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + try { + String jwt = parseJwt(request); + if (jwt != null && jwtUtils.validateJwtToken(jwt)) { + String username = jwtUtils.getUserNameFromJwtToken(jwt); + UserDetails userDetails = userDetailsService.loadUserByUsername(username); + UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); + authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + } catch (Exception e) { + logger.error("Cannot set user authentication: {}", e.getMessage()); + } + filterChain.doFilter(request, response); + } + + private String parseJwt(HttpServletRequest request) { + String headerAuth = request.getHeader("Authorization"); + if (StringUtils.hasText(headerAuth) && headerAuth.startsWith("Bearer ")) { + return headerAuth.substring(7); + } + return null; + } +} diff --git a/src/main/java/com/booleanuk/api/security/jwt/JwtUtils.java b/src/main/java/com/booleanuk/api/security/jwt/JwtUtils.java new file mode 100644 index 0000000..eb3ce39 --- /dev/null +++ b/src/main/java/com/booleanuk/api/security/jwt/JwtUtils.java @@ -0,0 +1,63 @@ +package com.booleanuk.api.security.jwt; + +import com.booleanuk.api.security.services.UserDetailsImpl; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.MalformedJwtException; +import io.jsonwebtoken.UnsupportedJwtException; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.util.Date; + +@Component +public class JwtUtils { + private static final Logger logger = LoggerFactory.getLogger(JwtUtils.class); + + @Value("${booleanuk.app.jwtSecret}") + private String jwtSecret; + + @Value("${booleanuk.app.jwtExpirationMs}") + private int jwtExpirationMs; + + public String generateJwtToken(Authentication authentication) { + UserDetailsImpl userPrincipal = (UserDetailsImpl) authentication.getPrincipal(); + + return Jwts.builder() + .subject((userPrincipal.getUsername())) + .issuedAt(new Date()) + .expiration(new Date((new Date()).getTime() + this.jwtExpirationMs)) + .signWith(this.key()) + .compact(); + } + + private SecretKey key() { + return Keys.hmacShaKeyFor(Decoders.BASE64.decode(this.jwtSecret)); + } + + public String getUserNameFromJwtToken(String token) { + return Jwts.parser().verifyWith(this.key()).build().parseSignedClaims(token).getPayload().getSubject(); + } + + public boolean validateJwtToken(String authToken) { + try { + Jwts.parser().verifyWith(this.key()).build().parse(authToken); + return true; + } catch (MalformedJwtException e) { + logger.error("Invalid JWT token: {}", e.getMessage()); + } catch (ExpiredJwtException e) { + logger.error("JWT token has expired: {}", e.getMessage()); + } catch (UnsupportedJwtException e) { + logger.error("JWT token is unsupported: {}", e.getMessage()); + } catch (IllegalArgumentException e) { + logger.error("JWT claims string is empty: {}", e.getMessage()); + } + return false; + } +} diff --git a/src/main/java/com/booleanuk/api/security/services/UserDetailsImpl.java b/src/main/java/com/booleanuk/api/security/services/UserDetailsImpl.java new file mode 100644 index 0000000..dc32317 --- /dev/null +++ b/src/main/java/com/booleanuk/api/security/services/UserDetailsImpl.java @@ -0,0 +1,58 @@ +package com.booleanuk.api.security.services; +import com.booleanuk.api.models.User; +import com.fasterxml.jackson.annotation.JsonIgnore; +import lombok.Getter; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.Collection; +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; + +@Getter +public class UserDetailsImpl implements UserDetails { + private static final long serialVersionUID = 1L; + + private int id; + private String username; + private String email; + + @JsonIgnore + private String password; + + private Collection authorities; + + public UserDetailsImpl(int id, String username, String email, String password, Collection authorities) { + this.id = id; + this.username = username; + this.email = email; + this.password = password; + this.authorities = authorities; + } + + public static UserDetailsImpl build(User user) { + List authorities = user.getRoles().stream() + .map(role -> new SimpleGrantedAuthority(role.getName().name())) + .collect(Collectors.toList()); + return new UserDetailsImpl( + user.getId(), + user.getUsername(), + user.getEmail(), + user.getPassword(), + authorities); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + UserDetailsImpl user = (UserDetailsImpl) o; + return Objects.equals(id, user.id); + } +} diff --git a/src/main/java/com/booleanuk/api/security/services/UserDetailsServiceImpl.java b/src/main/java/com/booleanuk/api/security/services/UserDetailsServiceImpl.java new file mode 100644 index 0000000..db03b57 --- /dev/null +++ b/src/main/java/com/booleanuk/api/security/services/UserDetailsServiceImpl.java @@ -0,0 +1,26 @@ +package com.booleanuk.api.security.services; + +import com.booleanuk.api.models.User; +import com.booleanuk.api.repositories.UserRepository; +import jakarta.transaction.Transactional; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +@Service +public class UserDetailsServiceImpl implements UserDetailsService { + @Autowired + UserRepository userRepository; + + @Override + @Transactional + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + User user = userRepository.findByUsername(username).orElseThrow( + () -> new UsernameNotFoundException("User not found with username: " + username) + ); + return UserDetailsImpl.build(user); + + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 02c2591..3641922 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -5,3 +5,24 @@ server: include-binding-errors: always include-stacktrace: never include-exception: false + +#spring: +# datasource: +# url: jdbc:postgresql://postgresdb:5432/postgres +# username: postgres +# password: mypassword +# max-active: 3 +# max-idle: 3 +# jpa: +# hibernate: +# ddl-auto: update +# properties: +# hibernate: +# dialect: org.hibernate.dialect.PostgreSQLDialect +# format_sql: true +# show_sql: true + +booleanuk: + app: + jwtSecret: ===============================BooleanUK=Spring============================================== + jwtExpirationMs: 86400000 \ No newline at end of file