diff --git a/build.gradle b/build.gradle index 2339281..b8ca311 100644 --- a/build.gradle +++ b/build.gradle @@ -20,14 +20,23 @@ repositories { dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' -// implementation 'org.springframework.boot:spring-boot-starter-jdbc' - testImplementation 'org.springframework.boot:spring-boot-starter-test' implementation "org.springframework.boot:spring-boot-starter-validation" - testRuntimeOnly 'org.junit.platform:junit-platform-launcher' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' - testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + implementation 'io.jsonwebtoken:jjwt-api:0.12.3' + implementation 'org.springframework.security:spring-security-crypto' + implementation 'org.projectlombok:lombok' + runtimeOnly 'com.mysql:mysql-connector-j' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.3' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.3' + + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + developmentOnly 'org.springframework.boot:spring-boot-docker-compose' + + annotationProcessor 'org.projectlombok:lombok' } tasks.named('test') { diff --git a/src/main/java/com/example/devSns/GlobalExceptionHandler.java b/src/main/java/com/example/devSns/GlobalExceptionHandler.java index 3c6f280..c84f44c 100644 --- a/src/main/java/com/example/devSns/GlobalExceptionHandler.java +++ b/src/main/java/com/example/devSns/GlobalExceptionHandler.java @@ -2,14 +2,13 @@ import com.example.devSns.dto.ErrorDto; -import com.example.devSns.exception.InvalidRequestException; -import com.example.devSns.exception.NotFoundException; -import com.example.devSns.exception.RequestConflictException; +import com.example.devSns.exception.*; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.ResponseEntity; import org.springframework.web.HttpRequestMethodNotSupportedException; import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingRequestCookieException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.client.HttpClientErrorException; @@ -49,6 +48,17 @@ public ResponseEntity handleRequestConflictException(RequestConflictEx return ResponseEntity.status(409).body(new ErrorDto(e.getMessage())); } + @ExceptionHandler(UnauthorizedException.class) + public ResponseEntity handleUnauthorizedException(UnauthorizedException e) { + return ResponseEntity.status(401).body(new ErrorDto(e.getMessage())); + } + + @ExceptionHandler(ForbiddenException.class) + public ResponseEntity handleForbiddenException(ForbiddenException e) { + return ResponseEntity.status(403).body(new ErrorDto(e.getMessage())); + } + + @ExceptionHandler(NoResourceFoundException.class) public ResponseEntity handleNoResource(NoResourceFoundException e) { // 필요하면 logger.debug로만 남기기 @@ -61,6 +71,11 @@ public ResponseEntity handleHttpRequestMethodNotSupportedException(Htt return ResponseEntity.status(404).body(new ErrorDto("Not Found")); } + @ExceptionHandler(MissingRequestCookieException.class) + public ResponseEntity handleMissingRequestCookieException(MissingRequestCookieException e) { + return ResponseEntity.status(400).body(new ErrorDto("Invalid Request")); + } + @ExceptionHandler(Exception.class) public ResponseEntity handleException(Exception e) { diff --git a/src/main/java/com/example/devSns/annotation/LoginUser.java b/src/main/java/com/example/devSns/annotation/LoginUser.java new file mode 100644 index 0000000..5dac7e4 --- /dev/null +++ b/src/main/java/com/example/devSns/annotation/LoginUser.java @@ -0,0 +1,11 @@ +package com.example.devSns.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +public @interface LoginUser { +} diff --git a/src/main/java/com/example/devSns/config/WebConfig.java b/src/main/java/com/example/devSns/config/WebConfig.java new file mode 100644 index 0000000..7be85c7 --- /dev/null +++ b/src/main/java/com/example/devSns/config/WebConfig.java @@ -0,0 +1,54 @@ +package com.example.devSns.config; + +import com.example.devSns.interceptor.JwtInterceptor; +import com.example.devSns.resolver.LoginUserArgumentResolver; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import java.util.List; +import java.util.Set; + +@Configuration +public class WebConfig implements WebMvcConfigurer { + private final JwtInterceptor jwtInterceptor; + private final LoginUserArgumentResolver loginUserArgumentResolver; + + public WebConfig(JwtInterceptor jwtInterceptor, LoginUserArgumentResolver loginUserArgumentResolver) { + this.loginUserArgumentResolver = loginUserArgumentResolver; + this.jwtInterceptor = jwtInterceptor; + } + + @Override + public void addInterceptors(InterceptorRegistry registry) { + jwtInterceptor.setMethodAllows("/posts", Set.of(HttpMethod.GET)); + jwtInterceptor.setMethodAllows("/posts/{postId}/comments", Set.of(HttpMethod.GET)); + jwtInterceptor.setMethodAllows("/members", Set.of(HttpMethod.POST)); + jwtInterceptor.setMethodAllows("/", Set.of(HttpMethod.GET)); + + registry.addInterceptor(jwtInterceptor) + .addPathPatterns("/**") + .excludePathPatterns( + "/auth/login", + "/auth/logout", + "/auth", + "/", + "/index.html", + "/favicon.ico", + "/error", + "/static/**", + "/css/**", + "/js/**", + "/images/**" + + ); + + } + + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(loginUserArgumentResolver); + } +} diff --git a/src/main/java/com/example/devSns/controller/AuthController.java b/src/main/java/com/example/devSns/controller/AuthController.java new file mode 100644 index 0000000..b750eec --- /dev/null +++ b/src/main/java/com/example/devSns/controller/AuthController.java @@ -0,0 +1,57 @@ +package com.example.devSns.controller; + +import com.example.devSns.dto.GenericDataDto; +import com.example.devSns.dto.auth.AuthResponseDto; +import com.example.devSns.dto.auth.LoginDto; +import com.example.devSns.service.AuthService; +import com.example.devSns.util.JwtUtil; +import jakarta.validation.Valid; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseCookie; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/auth") +public class AuthController { + private final AuthService authService; + private final JwtUtil jwtUtil; + + public AuthController(AuthService authService, JwtUtil jwtUtil) { + this.authService = authService; + this.jwtUtil = jwtUtil; + } + + @PostMapping("/login") + public ResponseEntity> login(@RequestBody @Valid LoginDto loginDto) { + AuthResponseDto authResponse = authService.login(loginDto); + ResponseCookie cookie = ResponseCookie + .from("refresh_token", authResponse.refreshToken()) + .httpOnly(true) + .secure(false) // 개발환경 + .sameSite("Strict") + .path("/auth") + .maxAge(jwtUtil.getRefreshExpiration()) + .build(); + + return ResponseEntity.ok() + .header(HttpHeaders.SET_COOKIE, cookie.toString()) + .body(new GenericDataDto<>(authResponse.accessToken())); + } + + @GetMapping("/logout") + public ResponseEntity logout(@CookieValue("refresh_token") String refreshToken) { + authService.logout(refreshToken); + return ResponseEntity.noContent().build(); + } + + + @GetMapping + public ResponseEntity> reAuthByRefreshToken(@CookieValue("refresh_token") String refreshToken) { + GenericDataDto accessToken = authService.reAuth(refreshToken); + return ResponseEntity.ok().body(accessToken); + } + + +} diff --git a/src/main/java/com/example/devSns/controller/CommentController.java b/src/main/java/com/example/devSns/controller/CommentController.java index 011f491..ddc32d6 100644 --- a/src/main/java/com/example/devSns/controller/CommentController.java +++ b/src/main/java/com/example/devSns/controller/CommentController.java @@ -1,9 +1,9 @@ package com.example.devSns.controller; +import com.example.devSns.annotation.LoginUser; import com.example.devSns.dto.GenericDataDto; import com.example.devSns.dto.comment.CommentCreateDto; import com.example.devSns.dto.comment.CommentResponseDto; -import com.example.devSns.dto.post.PostResponseDto; import com.example.devSns.service.CommentService; import jakarta.validation.Valid; import jakarta.validation.constraints.Positive; @@ -28,8 +28,8 @@ public CommentController(CommentService commentService) { @PostMapping - public ResponseEntity> create(@RequestBody @Valid CommentCreateDto commentCreateDto) { - Long id = commentService.create(commentCreateDto); + public ResponseEntity> create(@RequestBody @Valid CommentCreateDto commentCreateDto, @LoginUser Long memberId) { + Long id = commentService.create(commentCreateDto, memberId); URI uri = ServletUriComponentsBuilder .fromCurrentRequest() @@ -42,20 +42,21 @@ public ResponseEntity> create(@RequestBody @Valid CommentCr @GetMapping("/{id}") public ResponseEntity getOne(@PathVariable @Positive Long id) { - CommentResponseDto comment = commentService.findOne(id); + CommentResponseDto comment = commentService.findCommentById(id); return ResponseEntity.ok().body(comment); } @DeleteMapping("/{id}") - public ResponseEntity delete(@PathVariable @Positive Long id) { - commentService.delete(id); + public ResponseEntity delete(@PathVariable @Positive Long id, @LoginUser Long memberId) { + commentService.delete(id, memberId); return ResponseEntity.noContent().build(); } @PatchMapping("/{id}/contents") public ResponseEntity contents(@PathVariable @Positive Long id, - @RequestBody @Valid GenericDataDto contentsDto) { - CommentResponseDto comment = commentService.updateContent(id, contentsDto); + @RequestBody @Valid GenericDataDto contentsDto, + @LoginUser Long memberId) { + CommentResponseDto comment = commentService.updateContent(id, contentsDto, memberId); return ResponseEntity.ok().body(comment); } diff --git a/src/main/java/com/example/devSns/controller/FollowsController.java b/src/main/java/com/example/devSns/controller/FollowsController.java deleted file mode 100644 index 9b5ce05..0000000 --- a/src/main/java/com/example/devSns/controller/FollowsController.java +++ /dev/null @@ -1,42 +0,0 @@ -package com.example.devSns.controller; - - -import com.example.devSns.dto.GenericDataDto; -import com.example.devSns.dto.follow.FollowRequestDto; -import com.example.devSns.service.FollowsService; -import jakarta.validation.Valid; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; -import org.springframework.web.servlet.support.ServletUriComponentsBuilder; - -import java.net.URI; - -@RestController -@RequestMapping("/members") -public class FollowsController { - - private final FollowsService followsService; - public FollowsController(FollowsService followsService) { - this.followsService = followsService; - } - - @PostMapping("/{id}/follower") - public ResponseEntity follow(@PathVariable Long id, @RequestBody @Valid GenericDataDto followerIdDto) { - Long followId = followsService.follow(new FollowRequestDto(id, followerIdDto.data())); - - URI uri = ServletUriComponentsBuilder - .fromCurrentRequest() - .path("/{id}") - .buildAndExpand(followId) - .toUri(); - - return ResponseEntity.created(uri).build(); - } - - @DeleteMapping("/{id}/follower") - public ResponseEntity unfollow(@PathVariable Long id, @RequestBody @Valid GenericDataDto followerIdDto) { - followsService.unfollow(new FollowRequestDto(id, followerIdDto.data())); - return ResponseEntity.noContent().build(); - } - -} diff --git a/src/main/java/com/example/devSns/controller/LikeController.java b/src/main/java/com/example/devSns/controller/LikeController.java index d6750a6..66bee5f 100644 --- a/src/main/java/com/example/devSns/controller/LikeController.java +++ b/src/main/java/com/example/devSns/controller/LikeController.java @@ -1,6 +1,7 @@ package com.example.devSns.controller; +import com.example.devSns.annotation.LoginUser; import com.example.devSns.dto.GenericDataDto; import com.example.devSns.dto.likes.LikesRequestDto; import com.example.devSns.dto.member.MemberResponseDto; @@ -8,6 +9,7 @@ import com.example.devSns.service.LikesService; import jakarta.validation.Valid; import jakarta.validation.constraints.Positive; +import lombok.extern.java.Log; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; import org.springframework.data.domain.Sort; @@ -23,14 +25,14 @@ public LikeController(LikesService likeService) { } @PostMapping("/{id}/likes") - public ResponseEntity like(@PathVariable @Positive Long id, @RequestBody @Valid GenericDataDto memberIdDto) { - likeService.like(new LikesRequestDto(id, memberIdDto.data())); + public ResponseEntity like(@PathVariable @Positive Long id, @LoginUser Long memberId) { + likeService.like(new LikesRequestDto(id, memberId)); return ResponseEntity.noContent().build(); } @DeleteMapping("/{id}/likes") - public ResponseEntity unlike(@PathVariable @Positive Long id, @RequestBody @Valid GenericDataDto memberIdDto) { - likeService.unlike(new LikesRequestDto(id, memberIdDto.data())); + public ResponseEntity unlike(@PathVariable @Positive Long id, @LoginUser Long memberId) { + likeService.unlike(new LikesRequestDto(id, memberId)); return ResponseEntity.noContent().build(); } diff --git a/src/main/java/com/example/devSns/controller/MemberController.java b/src/main/java/com/example/devSns/controller/MemberController.java index 3c10ba1..0120494 100644 --- a/src/main/java/com/example/devSns/controller/MemberController.java +++ b/src/main/java/com/example/devSns/controller/MemberController.java @@ -1,8 +1,12 @@ package com.example.devSns.controller; +import com.example.devSns.annotation.LoginUser; import com.example.devSns.dto.GenericDataDto; +import com.example.devSns.dto.follow.FollowRequestDto; +import com.example.devSns.dto.follow.FollowsResponseDto; import com.example.devSns.dto.member.MemberCreateDto; import com.example.devSns.dto.member.MemberResponseDto; +import com.example.devSns.service.FollowsService; import com.example.devSns.service.MemberService; import jakarta.validation.Valid; import jakarta.validation.constraints.NotEmpty; @@ -22,8 +26,10 @@ public class MemberController { private final MemberService memberService; - public MemberController(MemberService memberService) { + private final FollowsService followsService; + public MemberController(MemberService memberService, FollowsService followsService) { this.memberService = memberService; + this.followsService = followsService; } @PostMapping @@ -60,6 +66,12 @@ public ResponseEntity> getMembersByNickname( } + @GetMapping("/{followingId}/follower/{followerId}") + public ResponseEntity getFollows(@PathVariable Long followingId, @PathVariable Long followerId) { + FollowsResponseDto follows = followsService.findFollows(new FollowRequestDto(followerId, followingId)); + return ResponseEntity.ok(follows); + } + @GetMapping("/{id}/follower") public ResponseEntity> getFollowers( @PageableDefault( @@ -70,10 +82,12 @@ public ResponseEntity> getFollowers( Pageable pageable, @PathVariable Long id ) { - Slice members = memberService.findFollowers(pageable, id); + Slice members = followsService.findFollowers(pageable, id); return ResponseEntity.ok(members); } + + @GetMapping("/{id}/following") public ResponseEntity> getFollowing( @PageableDefault( @@ -84,7 +98,26 @@ public ResponseEntity> getFollowing( Pageable pageable, @PathVariable Long id ) { - Slice members = memberService.findFollowing(pageable, id); + Slice members = followsService.findFollowing(pageable, id); return ResponseEntity.ok(members); } + + @PostMapping("/{id}/follower") + public ResponseEntity follow(@PathVariable Long id, @LoginUser Long memberId) { + Long followId = followsService.follow(new FollowRequestDto(memberId, id)); + + URI uri = ServletUriComponentsBuilder + .fromCurrentRequest() + .path("/{followerId}") + .buildAndExpand(memberId) + .toUri(); + + return ResponseEntity.created(uri).build(); + } + + @DeleteMapping("/{id}/follower") + public ResponseEntity unfollow(@PathVariable Long id, @LoginUser Long memberId) { + followsService.unfollow(new FollowRequestDto(memberId, id)); + return ResponseEntity.noContent().build(); + } } diff --git a/src/main/java/com/example/devSns/controller/PostController.java b/src/main/java/com/example/devSns/controller/PostController.java index 904a62c..4f949c3 100644 --- a/src/main/java/com/example/devSns/controller/PostController.java +++ b/src/main/java/com/example/devSns/controller/PostController.java @@ -1,10 +1,9 @@ package com.example.devSns.controller; +import com.example.devSns.annotation.LoginUser; import com.example.devSns.dto.GenericDataDto; -import com.example.devSns.dto.likes.LikesRequestDto; import com.example.devSns.dto.post.PostCreateDto; import com.example.devSns.dto.post.PostResponseDto; -import com.example.devSns.service.PostLikesService; import com.example.devSns.service.PostService; import jakarta.annotation.Nullable; import jakarta.validation.Valid; @@ -30,8 +29,10 @@ public PostController(PostService postService) { } @PostMapping - public ResponseEntity> create(@RequestBody @Valid PostCreateDto postCreateDto) { - Long id = postService.create(postCreateDto); + public ResponseEntity> create( + @RequestBody @Valid PostCreateDto postCreateDto, @LoginUser Long memberId + ) { + Long id = postService.create(postCreateDto, memberId); URI uri = ServletUriComponentsBuilder .fromCurrentRequest() @@ -44,13 +45,13 @@ public ResponseEntity> create(@RequestBody @Valid PostCreat @GetMapping("/{id}") public ResponseEntity getOne(@PathVariable @Positive Long id) { - PostResponseDto post = postService.findOne(id); + PostResponseDto post = postService.findPostById(id); return ResponseEntity.ok().body(post); } @DeleteMapping("/{id}") - public ResponseEntity delete(@PathVariable @Positive Long id) { - postService.delete(id); + public ResponseEntity delete(@PathVariable @Positive Long id, @LoginUser Long memberId) { + postService.delete(id, memberId); return ResponseEntity.noContent().build(); } @@ -74,9 +75,9 @@ public ResponseEntity> getAsPaginated( @PatchMapping("/{id}/contents") public ResponseEntity contents( - @PathVariable @Positive Long id, @RequestBody @Valid GenericDataDto contentsDto) { + @PathVariable @Positive Long id, @RequestBody @Valid GenericDataDto contentsDto, @LoginUser Long memberId) { - PostResponseDto post = postService.updateContent(id, contentsDto); + PostResponseDto post = postService.updateContent(id, contentsDto, memberId); return ResponseEntity.ok().body(post); } diff --git a/src/main/java/com/example/devSns/domain/BaseLikeEntity.java b/src/main/java/com/example/devSns/domain/BaseLikeEntity.java index b8e2349..ddc46f0 100644 --- a/src/main/java/com/example/devSns/domain/BaseLikeEntity.java +++ b/src/main/java/com/example/devSns/domain/BaseLikeEntity.java @@ -3,7 +3,7 @@ import jakarta.persistence.*; @MappedSuperclass -public abstract class BaseLikeEntity extends BaseTimeEntity{ +public abstract class BaseLikeEntity extends BaseTimeEntity implements OwnableEntity{ @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/src/main/java/com/example/devSns/domain/Comment.java b/src/main/java/com/example/devSns/domain/Comment.java index 88354e4..77c3dad 100644 --- a/src/main/java/com/example/devSns/domain/Comment.java +++ b/src/main/java/com/example/devSns/domain/Comment.java @@ -14,7 +14,7 @@ @Entity @Table(name="comments") -public class Comment extends BaseTimeEntity{ +public class Comment extends BaseTimeEntity implements OwnableEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/src/main/java/com/example/devSns/domain/Follows.java b/src/main/java/com/example/devSns/domain/Follows.java index 3efe33c..c5b222e 100644 --- a/src/main/java/com/example/devSns/domain/Follows.java +++ b/src/main/java/com/example/devSns/domain/Follows.java @@ -26,4 +26,12 @@ public Follows(Member follower, Member following) { public Long getId() { return id; } + + public Member getFollower() { + return follower; + } + + public Member getFollowing() { + return following; + } } diff --git a/src/main/java/com/example/devSns/domain/Member.java b/src/main/java/com/example/devSns/domain/Member.java index d607cd9..a395fd8 100644 --- a/src/main/java/com/example/devSns/domain/Member.java +++ b/src/main/java/com/example/devSns/domain/Member.java @@ -2,6 +2,7 @@ import jakarta.persistence.*; +import java.util.ArrayList; import java.util.List; @Entity @@ -11,6 +12,12 @@ public class Member extends BaseTimeEntity { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + @Column(name="email", nullable = false, unique = true) + private String email; + + @Column(name="password", nullable = false) + private String password; + @Column(name = "nickname", nullable = false, unique = true) private String nickname; @@ -29,12 +36,20 @@ public class Member extends BaseTimeEntity { @OneToMany(fetch = FetchType.LAZY, mappedBy = "member", cascade = CascadeType.ALL) private List postLikes; + @OneToMany(fetch = FetchType.LAZY, mappedBy = "member", cascade = CascadeType.ALL) + private List commentLikes; + + @OneToMany(fetch = FetchType.LAZY, mappedBy = "member", cascade = CascadeType.ALL, orphanRemoval = true) + private List refreshTokens = new ArrayList<>(); + @Version private Long version; public Member() {} - public Member(String nickname) { + public Member(String nickname, String email, String passwordHash) { this.nickname = nickname; + this.email = email; + this.password = passwordHash; } public Long getId() { @@ -44,5 +59,17 @@ public Long getId() { public String getNickname() { return nickname; } + + public String getEmail() { + return email; + } + + public String getPassword() { + return password; + } + + public void addRefreshToken(RefreshTokens refreshToken) { + refreshTokens.add(refreshToken); + } } diff --git a/src/main/java/com/example/devSns/domain/OwnableEntity.java b/src/main/java/com/example/devSns/domain/OwnableEntity.java new file mode 100644 index 0000000..1482ae7 --- /dev/null +++ b/src/main/java/com/example/devSns/domain/OwnableEntity.java @@ -0,0 +1,11 @@ +package com.example.devSns.domain; + + +public interface OwnableEntity { + + default boolean checkOwnership(Long memberId) { + return getMember().getId().equals(memberId); + } + + Member getMember(); +} diff --git a/src/main/java/com/example/devSns/domain/Post.java b/src/main/java/com/example/devSns/domain/Post.java index 0b71e32..5f3ebbc 100644 --- a/src/main/java/com/example/devSns/domain/Post.java +++ b/src/main/java/com/example/devSns/domain/Post.java @@ -8,7 +8,7 @@ @Entity @Table(name = "posts") -public class Post extends BaseTimeEntity{ +public class Post extends BaseTimeEntity implements OwnableEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/src/main/java/com/example/devSns/domain/RefreshTokens.java b/src/main/java/com/example/devSns/domain/RefreshTokens.java new file mode 100644 index 0000000..c529285 --- /dev/null +++ b/src/main/java/com/example/devSns/domain/RefreshTokens.java @@ -0,0 +1,53 @@ +package com.example.devSns.domain; + +import jakarta.persistence.*; + +import java.time.LocalDateTime; +import java.util.Base64; + +@Entity +@Table(name = "refresh_tokens") +public class RefreshTokens { + + @Id + @Column(columnDefinition = "BINARY(32)") + private byte []id; + + @Column(name = "valid_until", nullable = false) + private LocalDateTime validUntil; + + @Column(name = "valid", nullable = false) + private Boolean valid = true; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id") + private Member member; + + public RefreshTokens() {} + public RefreshTokens(byte []id, LocalDateTime validUntil, Member member) { + this.id = id; + this.validUntil = validUntil; + this.member = member; + } + + public void disable() { + this.valid = false; + } + + public boolean isValidToken() { + return (this.valid) && (this.validUntil.isAfter(LocalDateTime.now())); + } + + public void setMember(Member member) { + this.member = member; + } + + public Member getMember() { + return this.member; + } + + public byte[] getTokenHash() { + return id; + } + +} diff --git a/src/main/java/com/example/devSns/dto/GenericPairDto.java b/src/main/java/com/example/devSns/dto/GenericPairDto.java new file mode 100644 index 0000000..d8a5825 --- /dev/null +++ b/src/main/java/com/example/devSns/dto/GenericPairDto.java @@ -0,0 +1,4 @@ +package com.example.devSns.dto; + +public record GenericPairDto(T1 first, T2 second) { +} diff --git a/src/main/java/com/example/devSns/dto/auth/AuthResponseDto.java b/src/main/java/com/example/devSns/dto/auth/AuthResponseDto.java new file mode 100644 index 0000000..3f8235a --- /dev/null +++ b/src/main/java/com/example/devSns/dto/auth/AuthResponseDto.java @@ -0,0 +1,9 @@ +package com.example.devSns.dto.auth; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record AuthResponseDto( + @JsonProperty("access_token") String accessToken, + @JsonProperty("refresh_token") String refreshToken +) { +} diff --git a/src/main/java/com/example/devSns/dto/auth/LoginDto.java b/src/main/java/com/example/devSns/dto/auth/LoginDto.java new file mode 100644 index 0000000..0f9877a --- /dev/null +++ b/src/main/java/com/example/devSns/dto/auth/LoginDto.java @@ -0,0 +1,12 @@ +package com.example.devSns.dto.auth; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +public record LoginDto( + @NotNull @Email String email, + @NotBlank @Size(min = 6, max = 20) String password +) { +} diff --git a/src/main/java/com/example/devSns/dto/comment/CommentCreateDto.java b/src/main/java/com/example/devSns/dto/comment/CommentCreateDto.java index 71eedd0..71266de 100644 --- a/src/main/java/com/example/devSns/dto/comment/CommentCreateDto.java +++ b/src/main/java/com/example/devSns/dto/comment/CommentCreateDto.java @@ -7,7 +7,6 @@ public record CommentCreateDto( @NotNull @JsonProperty("post_id") Long postId, - @NotNull @JsonProperty("member_id") Long memberId, @NotEmpty String content ) { } diff --git a/src/main/java/com/example/devSns/dto/comment/CommentResponseDto.java b/src/main/java/com/example/devSns/dto/comment/CommentResponseDto.java index 41f0b30..e77ba44 100644 --- a/src/main/java/com/example/devSns/dto/comment/CommentResponseDto.java +++ b/src/main/java/com/example/devSns/dto/comment/CommentResponseDto.java @@ -12,6 +12,7 @@ public record CommentResponseDto( Long id, @JsonProperty("post_id") Long postId, String content, + @JsonProperty("user_id") Long userId, @JsonProperty("user_name") String userName, @JsonProperty("like_count") Long likeCount, @JsonProperty("created_at") LocalDateTime createdAt, @@ -23,6 +24,7 @@ public static CommentResponseDto from(Comment comment) { comment.getId(), comment.getPost().getId(), comment.getContent(), + comment.getMember().getId(), comment.getMember().getNickname(), comment.getCommentLikes().stream().count(), comment.getCreatedAt(), diff --git a/src/main/java/com/example/devSns/dto/follow/FollowsDto.java b/src/main/java/com/example/devSns/dto/follow/FollowsDto.java new file mode 100644 index 0000000..af8da35 --- /dev/null +++ b/src/main/java/com/example/devSns/dto/follow/FollowsDto.java @@ -0,0 +1,5 @@ +package com.example.devSns.dto.follow; + +import com.example.devSns.domain.Member; + +public record FollowsDto(Member follower, Member following) {} diff --git a/src/main/java/com/example/devSns/dto/follow/FollowsResponseDto.java b/src/main/java/com/example/devSns/dto/follow/FollowsResponseDto.java new file mode 100644 index 0000000..2f6928a --- /dev/null +++ b/src/main/java/com/example/devSns/dto/follow/FollowsResponseDto.java @@ -0,0 +1,9 @@ +package com.example.devSns.dto.follow; + +import com.example.devSns.dto.member.MemberResponseDto; + +public record FollowsResponseDto( + MemberResponseDto follower, + MemberResponseDto following +) { +} diff --git a/src/main/java/com/example/devSns/dto/member/MemberCreateDto.java b/src/main/java/com/example/devSns/dto/member/MemberCreateDto.java index d9ea0d8..c5b06f4 100644 --- a/src/main/java/com/example/devSns/dto/member/MemberCreateDto.java +++ b/src/main/java/com/example/devSns/dto/member/MemberCreateDto.java @@ -1,8 +1,12 @@ package com.example.devSns.dto.member; +import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; public record MemberCreateDto( - @NotBlank String nickname + @NotBlank String nickname, + @NotBlank @Email String email, + @NotBlank @Size(min = 6, max = 20) String password ) { } diff --git a/src/main/java/com/example/devSns/dto/post/PostCreateDto.java b/src/main/java/com/example/devSns/dto/post/PostCreateDto.java index ceabd06..da15d0d 100644 --- a/src/main/java/com/example/devSns/dto/post/PostCreateDto.java +++ b/src/main/java/com/example/devSns/dto/post/PostCreateDto.java @@ -6,7 +6,6 @@ import jakarta.validation.constraints.NotNull; public record PostCreateDto( - @NotNull @NotEmpty String content, - @NotNull @JsonProperty("member_id") Long memberId + @NotNull @NotEmpty String content ) { } diff --git a/src/main/java/com/example/devSns/dto/post/PostResponseDto.java b/src/main/java/com/example/devSns/dto/post/PostResponseDto.java index dd45323..6ff709e 100644 --- a/src/main/java/com/example/devSns/dto/post/PostResponseDto.java +++ b/src/main/java/com/example/devSns/dto/post/PostResponseDto.java @@ -15,6 +15,7 @@ public record PostResponseDto( Long id, String content, + @JsonProperty("user_id") Long userId, @JsonProperty("user_name") String userName, @JsonProperty("like_count") Long likeCount, @JsonProperty("created_at") LocalDateTime createdAt, @@ -28,11 +29,12 @@ public static PostResponseDto from(Post post) { return new PostResponseDto( post.getId(), post.getContent(), + post.getMember().getId(), post.getMember().getNickname(), - post.getPostLikes().stream().count(), + post.getPostLikes() == null ? 0L : post.getPostLikes().stream().count(), post.getCreatedAt(), post.getUpdatedAt(), - post.getComments().stream().count() + post.getComments() == null ? 0L : post.getComments().stream().count() ); } } diff --git a/src/main/java/com/example/devSns/exception/ForbiddenException.java b/src/main/java/com/example/devSns/exception/ForbiddenException.java new file mode 100644 index 0000000..b7297da --- /dev/null +++ b/src/main/java/com/example/devSns/exception/ForbiddenException.java @@ -0,0 +1,7 @@ +package com.example.devSns.exception; + +public class ForbiddenException extends RuntimeException { + public ForbiddenException(String message) { + super(message); + } +} diff --git a/src/main/java/com/example/devSns/exception/UnauthorizedException.java b/src/main/java/com/example/devSns/exception/UnauthorizedException.java new file mode 100644 index 0000000..c8e37c0 --- /dev/null +++ b/src/main/java/com/example/devSns/exception/UnauthorizedException.java @@ -0,0 +1,7 @@ +package com.example.devSns.exception; + +public class UnauthorizedException extends RuntimeException { + public UnauthorizedException(String message) { + super(message); + } +} diff --git a/src/main/java/com/example/devSns/interceptor/JwtInterceptor.java b/src/main/java/com/example/devSns/interceptor/JwtInterceptor.java new file mode 100644 index 0000000..9d4bf2d --- /dev/null +++ b/src/main/java/com/example/devSns/interceptor/JwtInterceptor.java @@ -0,0 +1,80 @@ +package com.example.devSns.interceptor; + +import com.example.devSns.dto.GenericPairDto; +import com.example.devSns.exception.NotFoundException; +import com.example.devSns.util.JwtUtil; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.HttpMethod; +import org.springframework.stereotype.Component; +import org.springframework.web.cors.CorsUtils; +import org.springframework.web.servlet.HandlerInterceptor; +import org.springframework.web.servlet.HandlerMapping; +import lombok.extern.slf4j.Slf4j; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + + +@Slf4j +@Component +public class JwtInterceptor implements HandlerInterceptor { + + private final JwtUtil jwtUtil; + private final Map> allowedMethods; + + + public JwtInterceptor(JwtUtil jwtUtil) { + this.jwtUtil = jwtUtil; + this.allowedMethods = new HashMap<>(); + } + + public void setMethodAllows(String url, Set allowedHttpMethods) { + allowedMethods.put(url, allowedHttpMethods); + } + + private boolean checkMethodAllows(String url, HttpMethod httpMethod) { + return allowedMethods.containsKey(url) && allowedMethods.get(url).contains(httpMethod); + } + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { + if (CorsUtils.isPreFlightRequest(request)) { + return true; + } + String pattern = (String) request.getAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE); + + if (pattern == null) { + return false; + } + + if (checkMethodAllows(pattern, HttpMethod.valueOf(request.getMethod()))) { + return true; + } + + String authHeader = request.getHeader("Authorization"); + log.info("Authorization header: {}", authHeader); + if (authHeader == null || !authHeader.startsWith("Bearer ")) { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.getWriter().write("Unauthorized"); + return false; + } + + // "Bearer " 제거 + String token = authHeader.substring(7); + + if (!jwtUtil.validateToken(token)) { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.getWriter().write("Unauthorized"); + return false; + } + + Long userId = jwtUtil.getUserIdFromToken(token); + request.setAttribute("userId", userId); + + return true; + } + +} diff --git a/src/main/java/com/example/devSns/repository/CommentRepository.java b/src/main/java/com/example/devSns/repository/CommentRepository.java index 467fbd3..6b23afe 100644 --- a/src/main/java/com/example/devSns/repository/CommentRepository.java +++ b/src/main/java/com/example/devSns/repository/CommentRepository.java @@ -27,6 +27,7 @@ public interface CommentRepository extends JpaRepository { c.id, c.post.id, c.content, + m.id, m.nickname, (select count(*) as likes from CommentLikes cl where c = cl.comment), c.createdAt, @@ -44,6 +45,7 @@ public interface CommentRepository extends JpaRepository { c.id, c.post.id, c.content, + m.id, m.nickname, (select count(*) as likes from CommentLikes cl where c = cl.comment), c.createdAt, diff --git a/src/main/java/com/example/devSns/repository/FollowsRepository.java b/src/main/java/com/example/devSns/repository/FollowsRepository.java index 7f3e955..147a7dd 100644 --- a/src/main/java/com/example/devSns/repository/FollowsRepository.java +++ b/src/main/java/com/example/devSns/repository/FollowsRepository.java @@ -2,8 +2,34 @@ import com.example.devSns.domain.Follows; import com.example.devSns.domain.Member; +import com.example.devSns.dto.member.MemberResponseDto; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +import java.util.Optional; public interface FollowsRepository extends JpaRepository { - Follows findByFollowerAndFollowing(Member follower, Member following); + Optional findByFollowerAndFollowing(Member follower, Member following); + Optional findByFollowerIdAndFollowingId(Long followerId, Long followingId); + + + @Query(""" + SELECT new com.example.devSns.dto.member.MemberResponseDto( + f.follower.id, f.follower.nickname + ) + FROM Follows f + WHERE f.following.id = :id + """) + Slice findFollowersOf(Long id, Pageable pageable); + + @Query(""" + SELECT new com.example.devSns.dto.member.MemberResponseDto( + f.following.id, f.following.nickname + ) + FROM Follows f + WHERE f.follower.id = :id + """) + Slice findFollowingsOf(Long id, Pageable pageable); } diff --git a/src/main/java/com/example/devSns/repository/MemberRepository.java b/src/main/java/com/example/devSns/repository/MemberRepository.java index 52e045b..2e14081 100644 --- a/src/main/java/com/example/devSns/repository/MemberRepository.java +++ b/src/main/java/com/example/devSns/repository/MemberRepository.java @@ -7,28 +7,15 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; +import javax.swing.text.html.Option; import java.util.List; import java.util.Optional; public interface MemberRepository extends JpaRepository { + Optional findByEmail(String email); + Slice findMembersByNickname(String nickname, Pageable pageable); List findMembersByIdIn(List id); - @Query(""" - SELECT new com.example.devSns.dto.member.MemberResponseDto( - f.follower.id, f.follower.nickname - ) - FROM Follows f - WHERE f.following.id = :id - """) - Slice findFollowings(Long id, Pageable pageable); - @Query(""" - SELECT new com.example.devSns.dto.member.MemberResponseDto( - f.following.id, f.following.nickname - ) - FROM Follows f - WHERE f.follower.id = :id - """) - Slice findFollowers(Long id, Pageable pageable); } diff --git a/src/main/java/com/example/devSns/repository/PostRepository.java b/src/main/java/com/example/devSns/repository/PostRepository.java index 7abfab6..38b3a3f 100644 --- a/src/main/java/com/example/devSns/repository/PostRepository.java +++ b/src/main/java/com/example/devSns/repository/PostRepository.java @@ -24,6 +24,7 @@ public interface PostRepository extends JpaRepository { SELECT NEW com.example.devSns.dto.post.PostResponseDto( p.id, p.content, + m.id, m.nickname, (select count(*) as likes from PostLikes pl where p = pl.post), p.createdAt, @@ -39,6 +40,7 @@ public interface PostRepository extends JpaRepository { SELECT NEW com.example.devSns.dto.post.PostResponseDto( p.id, p.content, + m.id, m.nickname, (select count(*) as likes from PostLikes pl where p = pl.post), p.createdAt, diff --git a/src/main/java/com/example/devSns/repository/RefreshTokensRepository.java b/src/main/java/com/example/devSns/repository/RefreshTokensRepository.java new file mode 100644 index 0000000..066c8c5 --- /dev/null +++ b/src/main/java/com/example/devSns/repository/RefreshTokensRepository.java @@ -0,0 +1,9 @@ +package com.example.devSns.repository; + +import com.example.devSns.domain.RefreshTokens; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface RefreshTokensRepository extends JpaRepository { + + +} diff --git a/src/main/java/com/example/devSns/resolver/LoginUserArgumentResolver.java b/src/main/java/com/example/devSns/resolver/LoginUserArgumentResolver.java new file mode 100644 index 0000000..b2db2c6 --- /dev/null +++ b/src/main/java/com/example/devSns/resolver/LoginUserArgumentResolver.java @@ -0,0 +1,30 @@ +package com.example.devSns.resolver; + +import com.example.devSns.annotation.LoginUser; +import org.springframework.core.MethodParameter; +import org.springframework.lang.NonNullApi; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.context.request.RequestAttributes; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +@Component +public class LoginUserArgumentResolver implements HandlerMethodArgumentResolver { + + @Override + public boolean supportsParameter(MethodParameter parameter) { + boolean hasLoginAnnotation = parameter.hasParameterAnnotation(LoginUser.class); + + boolean hasLongType = Long.class.isAssignableFrom(parameter.getParameterType()); + + return hasLoginAnnotation && hasLongType; + } + + @Override + public Object resolveArgument( MethodParameter parameter, ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception { + return webRequest.getAttribute("userId", RequestAttributes.SCOPE_REQUEST); + } +} diff --git a/src/main/java/com/example/devSns/service/AuthService.java b/src/main/java/com/example/devSns/service/AuthService.java new file mode 100644 index 0000000..94b0b3a --- /dev/null +++ b/src/main/java/com/example/devSns/service/AuthService.java @@ -0,0 +1,86 @@ +package com.example.devSns.service; + +import com.example.devSns.domain.Member; +import com.example.devSns.domain.RefreshTokens; +import com.example.devSns.dto.GenericDataDto; +import com.example.devSns.dto.auth.AuthResponseDto; +import com.example.devSns.dto.auth.LoginDto; +import com.example.devSns.exception.NotFoundException; +import com.example.devSns.exception.UnauthorizedException; +import com.example.devSns.repository.MemberRepository; +import com.example.devSns.repository.RefreshTokensRepository; +import com.example.devSns.util.JwtUtil; +import org.springframework.security.crypto.bcrypt.BCrypt; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.Base64; + +import static java.util.Base64.getDecoder; + + +@Service +@Transactional(readOnly = true) +public class AuthService { + private final JwtUtil jwtUtil; + private final MemberRepository memberRepository; + private final RefreshTokensRepository refreshTokensRepository; + + public AuthService(JwtUtil jwtUtil, MemberRepository memberRepository, RefreshTokensRepository refreshTokensRepository) { + this.jwtUtil = jwtUtil; + this.memberRepository = memberRepository; + this.refreshTokensRepository = refreshTokensRepository; + } + + @Transactional + public AuthResponseDto login(LoginDto loginDto) { + Member member = memberRepository.findByEmail(loginDto.email()) + .orElseThrow(()->new NotFoundException("Invalid email or password")); + + String hashedPassword = member.getPassword(); + if (BCrypt.checkpw(loginDto.password(), hashedPassword)) { + String accessToken = jwtUtil.generateAccessToken(member.getId()); + byte[] rawRefreshToken = jwtUtil.generateRefreshToken(); + byte[] hashedRefreshToken = jwtUtil.hashToken(rawRefreshToken); + + RefreshTokens refreshToken = new RefreshTokens(hashedRefreshToken, + LocalDateTime.now().plusSeconds(jwtUtil.getRefreshExpiration()), + member + ); + refreshTokensRepository.save(refreshToken); + + return new AuthResponseDto(accessToken, Base64.getEncoder().encodeToString(rawRefreshToken)); + } + else throw new NotFoundException("Invalid email or password"); + } + + @Transactional + public void logout(String refreshToken) { + byte[] rawToken = Base64.getDecoder().decode(refreshToken); + byte[] hashedToken = jwtUtil.hashToken(rawToken); + RefreshTokens refreshTokens = refreshTokensRepository.findById(hashedToken) + .orElseThrow(()->new IllegalStateException("Logout Failed")); + + refreshTokens.disable(); + } + + public GenericDataDto reAuth(String refreshToken) { + byte[] rawToken = Base64.getDecoder().decode(refreshToken); + byte[] hashedToken = jwtUtil.hashToken(rawToken); + + RefreshTokens refreshTokens = refreshTokensRepository.findById(hashedToken) + .orElseThrow(()->new UnauthorizedException("Unauthorized")); + + if (!refreshTokens.isValidToken()) { + throw new UnauthorizedException("Unauthorized"); + } + + Member targetMember = refreshTokens.getMember(); + String accessToken = jwtUtil.generateAccessToken(targetMember.getId()); + + return new GenericDataDto<>(accessToken); + } + +} diff --git a/src/main/java/com/example/devSns/service/CommentService.java b/src/main/java/com/example/devSns/service/CommentService.java index b277ac4..dd51bc2 100644 --- a/src/main/java/com/example/devSns/service/CommentService.java +++ b/src/main/java/com/example/devSns/service/CommentService.java @@ -7,7 +7,7 @@ import com.example.devSns.dto.PaginatedDto; import com.example.devSns.dto.comment.CommentCreateDto; import com.example.devSns.dto.comment.CommentResponseDto; -import com.example.devSns.dto.post.PostResponseDto; +import com.example.devSns.exception.ForbiddenException; import com.example.devSns.exception.InvalidRequestException; import com.example.devSns.exception.NotFoundException; import com.example.devSns.repository.CommentRepository; @@ -35,8 +35,9 @@ public CommentService(CommentRepository commentRepository, PostRepository postRe } @Transactional - public Long create(CommentCreateDto commentCreateDto) { - Member member = memberRepository.findById(commentCreateDto.memberId()).orElseThrow(() -> new NotFoundException("Member not found")); + public Long create(CommentCreateDto commentCreateDto, Long memberId) { + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new NotFoundException("Member not found")); Post post = postRepository.findById(commentCreateDto.postId()) .orElseThrow(()->new InvalidRequestException("Invalid Request.")); @@ -44,27 +45,29 @@ public Long create(CommentCreateDto commentCreateDto) { return commentRepository.save(comment).getId(); } - public CommentResponseDto findOne(Long id) { + public CommentResponseDto findCommentById(Long id) { Comment comment = commentRepository.findById(id).orElseThrow(()->new NotFoundException("comment not found")); return CommentResponseDto.from(comment); } @Transactional - public void delete(Long id) { + public void delete(Long id, Long memberId) { Comment comment = commentRepository.findById(id).orElseThrow(()->new NotFoundException("comment not found")); + if (!comment.checkOwnership(memberId)) throw new ForbiddenException("Forbidden"); commentRepository.delete(comment); } @Transactional - public CommentResponseDto updateContent(Long id, GenericDataDto contentsDto) { + public CommentResponseDto updateContent(Long id, GenericDataDto contentsDto, Long memberId) { if (contentsDto.data() == null || contentsDto.data().isEmpty()) throw new InvalidRequestException("Invalid Request."); Comment comment = commentRepository.findById(id).orElseThrow(()->new NotFoundException("comment not found")); + if (!comment.checkOwnership(memberId)) throw new ForbiddenException("Forbidden"); comment.setContent(contentsDto.data()); - return findOne(id); + return findCommentById(id); } public PaginatedDto> findAsPaginated(GenericDataDto idDto, Long postId) { diff --git a/src/main/java/com/example/devSns/service/FollowsService.java b/src/main/java/com/example/devSns/service/FollowsService.java index bec0bf0..063e89e 100644 --- a/src/main/java/com/example/devSns/service/FollowsService.java +++ b/src/main/java/com/example/devSns/service/FollowsService.java @@ -4,13 +4,21 @@ import com.example.devSns.domain.Member; import com.example.devSns.dto.GenericDataDto; import com.example.devSns.dto.follow.FollowRequestDto; +import com.example.devSns.dto.follow.FollowsDto; +import com.example.devSns.dto.follow.FollowsResponseDto; +import com.example.devSns.dto.member.MemberResponseDto; +import com.example.devSns.exception.InvalidRequestException; import com.example.devSns.exception.NotFoundException; import com.example.devSns.repository.FollowsRepository; import com.example.devSns.repository.MemberRepository; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; @Service +@Transactional(readOnly = true) public class FollowsService { private final FollowsRepository followsRepository; private final MemberRepository memberRepository; @@ -20,26 +28,55 @@ public FollowsService(FollowsRepository followsRepository, MemberRepository memb this.memberRepository = memberRepository; } + public FollowsResponseDto findFollows(FollowRequestDto followRequestDto) { + Follows follows = followsRepository.findByFollowerIdAndFollowingId(followRequestDto.followerId(), followRequestDto.followingId()) + .orElseThrow(()->new NotFoundException("Follows not found")); + + return new FollowsResponseDto( + MemberResponseDto.from(follows.getFollower()), + MemberResponseDto.from(follows.getFollowing()) + ); + } + + @Transactional public Long follow(FollowRequestDto followRequestDto) { + if (followRequestDto.followerId().equals(followRequestDto.followingId())) + throw new InvalidRequestException("You can't follow yourself"); + FollowsDto fd = findFollowerAndFollowing(followRequestDto); + boolean followExists = followsRepository.findByFollowerAndFollowing(fd.follower(), fd.following()).isPresent(); + if (followExists) throw new InvalidRequestException("Follower already exists"); Follows follows = new Follows(fd.follower(), fd.following()); return followsRepository.save(follows).getId(); } + @Transactional public void unfollow(FollowRequestDto followRequestDto) { FollowsDto fd = findFollowerAndFollowing(followRequestDto); - Follows follows = followsRepository.findByFollowerAndFollowing(fd.follower(), fd.following()); + Follows follows = followsRepository.findByFollowerAndFollowing(fd.follower(), fd.following()) + .orElseThrow(() -> new NotFoundException("Follower not found")); followsRepository.delete(follows); } - private record FollowsDto(Member follower, Member following) {} + private FollowsDto findFollowerAndFollowing(FollowRequestDto followRequestDto) { - Member follower = memberRepository.findById(followRequestDto.followerId()).orElseThrow(()->new NotFoundException("Follower not found")); - Member following = memberRepository.findById(followRequestDto.followingId()).orElseThrow(()->new NotFoundException("Following not found")); + Member follower = memberRepository.findById(followRequestDto.followerId()) + .orElseThrow(()->new NotFoundException("Follower not found")); + Member following = memberRepository.findById(followRequestDto.followingId()) + .orElseThrow(()->new NotFoundException("Following not found")); return new FollowsDto(follower, following); } + public Slice findFollowers(Pageable pageable, Long memberId) { + return followsRepository.findFollowersOf(memberId, pageable); + } + + public Slice findFollowing(Pageable pageable, Long memberId) { + return followsRepository.findFollowingsOf(memberId, pageable); + } + + } diff --git a/src/main/java/com/example/devSns/service/MemberService.java b/src/main/java/com/example/devSns/service/MemberService.java index 1eb0b6e..98e502b 100644 --- a/src/main/java/com/example/devSns/service/MemberService.java +++ b/src/main/java/com/example/devSns/service/MemberService.java @@ -3,10 +3,12 @@ import com.example.devSns.domain.Member; import com.example.devSns.dto.member.MemberCreateDto; import com.example.devSns.dto.member.MemberResponseDto; +import com.example.devSns.exception.InvalidRequestException; import com.example.devSns.exception.NotFoundException; import com.example.devSns.repository.MemberRepository; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; +import org.springframework.security.crypto.bcrypt.BCrypt; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -21,7 +23,12 @@ public MemberService(MemberRepository memberRepository) { @Transactional public Long create(MemberCreateDto memberCreateDto) { - Member member = new Member(memberCreateDto.nickname()); + if (memberRepository.findByEmail(memberCreateDto.email()).isPresent()) { + throw new InvalidRequestException("Already signed up"); + } + + String passwordHash = BCrypt.hashpw(memberCreateDto.password(), BCrypt.gensalt()); + Member member = new Member(memberCreateDto.nickname(), memberCreateDto.email(), passwordHash); return memberRepository.save(member).getId(); } @@ -36,13 +43,5 @@ public Slice findByNickname(Pageable pageable,String nickname return members.map(MemberResponseDto::from); } - public Slice findFollowers(Pageable pageable, Long memberId) { - return memberRepository.findFollowers(memberId, pageable); - } - - public Slice findFollowing(Pageable pageable, Long memberId) { - return memberRepository.findFollowings(memberId, pageable); - } - } diff --git a/src/main/java/com/example/devSns/service/PostLikesService.java b/src/main/java/com/example/devSns/service/PostLikesService.java index 1239bf2..a44c049 100644 --- a/src/main/java/com/example/devSns/service/PostLikesService.java +++ b/src/main/java/com/example/devSns/service/PostLikesService.java @@ -23,9 +23,8 @@ public class PostLikesService extends LikesService { private final PostLikesRepository postLikesRepository; private final MemberRepository memberRepository; private final PostRepository postRepository; - public PostLikesService(PostLikesRepository postLikesRepository, - MemberRepository memberRepository, - PostRepository postRepository) { + + public PostLikesService(PostLikesRepository postLikesRepository, MemberRepository memberRepository, PostRepository postRepository) { this.postLikesRepository = postLikesRepository; this.memberRepository = memberRepository; this.postRepository = postRepository; diff --git a/src/main/java/com/example/devSns/service/PostService.java b/src/main/java/com/example/devSns/service/PostService.java index edfe05d..af2abf2 100644 --- a/src/main/java/com/example/devSns/service/PostService.java +++ b/src/main/java/com/example/devSns/service/PostService.java @@ -5,9 +5,9 @@ import com.example.devSns.dto.GenericDataDto; import com.example.devSns.dto.post.PostCreateDto; import com.example.devSns.dto.post.PostResponseDto; +import com.example.devSns.exception.ForbiddenException; import com.example.devSns.exception.InvalidRequestException; import com.example.devSns.exception.NotFoundException; -import com.example.devSns.repository.CommentRepository; import com.example.devSns.repository.MemberRepository; import com.example.devSns.repository.PostRepository; import org.springframework.data.domain.Pageable; @@ -28,32 +28,35 @@ public PostService(PostRepository postRepository, MemberRepository memberReposit } @Transactional - public Long create(PostCreateDto postCreateDto) { - Member member = memberRepository.findById(postCreateDto.memberId()).orElseThrow(()->new NotFoundException("member not found")); + public Long create(PostCreateDto postCreateDto, Long memberId) { + Member member = memberRepository.findById(memberId) + .orElseThrow(()->new NotFoundException("member not found")); Post post = Post.create(postCreateDto.content(), member); return postRepository.save(post).getId(); } - public PostResponseDto findOne(Long id) { + public PostResponseDto findPostById(Long id) { Post post = postRepository.findById(id).orElseThrow(()->new NotFoundException("post not found")); return PostResponseDto.from(post); } @Transactional - public void delete(Long id) { + public void delete(Long id, Long memberId) { Post post = postRepository.findById(id).orElseThrow(()->new NotFoundException("post not found")); + if (!post.checkOwnership(memberId)) throw new ForbiddenException("Forbidden"); postRepository.delete(post); } @Transactional - public PostResponseDto updateContent(Long id, GenericDataDto contentsDto) { + public PostResponseDto updateContent(Long id, GenericDataDto contentsDto, Long memberId) { if (contentsDto.data() == null || contentsDto.data().isEmpty()) throw new InvalidRequestException("Invalid request."); Post post = postRepository.findById(id).orElseThrow(()->new NotFoundException("post not found")); + if (!post.checkOwnership(memberId)) throw new ForbiddenException("Forbidden"); post.setContent(contentsDto.data()); - return findOne(id); + return findPostById(id); } public Slice findAsSlice(Pageable pageable) { diff --git a/src/main/java/com/example/devSns/util/JwtUtil.java b/src/main/java/com/example/devSns/util/JwtUtil.java new file mode 100644 index 0000000..d8ca50b --- /dev/null +++ b/src/main/java/com/example/devSns/util/JwtUtil.java @@ -0,0 +1,90 @@ +package com.example.devSns.util; + +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.time.LocalDateTime; +import java.util.Base64; +import java.util.Date; + +@Component +public class JwtUtil { + + private final String SECRET; + private final Long EXPIRATION; + private final Long REFRESH_EXPIRATION; + private final SecureRandom secureRandom; + + + public JwtUtil( + @Value("${jwt.secret}") String secret, + @Value("${jwt.expiration}") Long expiration, + @Value("${jwt.refresh_expiration}") Long refresh_expiration + ) { + this.SECRET = secret; + this.EXPIRATION = expiration * 1000; + this.REFRESH_EXPIRATION = refresh_expiration ; + this.secureRandom = new SecureRandom(); + } + + private SecretKey getKey() { + return Keys.hmacShaKeyFor(SECRET.getBytes(StandardCharsets.UTF_8)); + } + + public Long getRefreshExpiration() { + return REFRESH_EXPIRATION; + + } + + public String generateAccessToken(Long userId) { + return Jwts.builder() + .subject(userId.toString()) + .issuedAt(new Date()) + .expiration(new Date(System.currentTimeMillis() + EXPIRATION)) + .signWith(getKey()) + .compact(); + } + + public byte[] generateRefreshToken() { + byte[] randomBytes = new byte[32]; + secureRandom.nextBytes(randomBytes); + return randomBytes; + } + + public byte[] hashToken(byte[] tokenBytes) { + try { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + return md.digest(tokenBytes); // 32바이트 + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 not supported", e); + } + } + + public Long getUserIdFromToken(String token) { + return Long.valueOf( + Jwts.parser() + .verifyWith(getKey()) + .build() + .parseSignedClaims(token) + .getPayload() + .getSubject() + ); + } + + public boolean validateToken(String token) { + try { + Jwts.parser().verifyWith(getKey()).build().parseSignedClaims(token); + return true; + } catch (Exception e) { + return false; + } + } + +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index b2f0ea3..c204355 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -6,4 +6,8 @@ spring.datasource.username=user spring.datasource.password=password spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver -spring.sql.init.mode=always \ No newline at end of file +spring.sql.init.mode=never + +jwt.secret=4bd6879e-cf49-4d59-a345-29466553cec5 +jwt.expiration=900 +jwt.refresh_expiration=86400 \ No newline at end of file diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql index 841d89a..34a79cd 100644 --- a/src/main/resources/schema.sql +++ b/src/main/resources/schema.sql @@ -4,6 +4,7 @@ drop table if exists comments cascade; drop table if exists posts_likes cascade; drop table if exists posts cascade; drop table if exists member_follows cascade; +drop table if exists refresh_tokens cascade ; drop table if exists members cascade; @@ -11,11 +12,25 @@ drop table if exists members cascade; create table if not exists members ( id BIGINT auto_increment primary key, - nickname varchar(255) not null unique, + email varchar(255) not null unique, + password varchar(255) not null, + nickname varchar(255) not null, created_at TIMESTAMP not null, updated_at TIMESTAMP not null, version BIGINT default 0 + ); +create index members_nickname_index on members(nickname); +create index members_email_index on members(email); + +create table if not exists refresh_tokens( + id BINARY(32) primary key, + member_id BIGINT, + valid_until DATETIME not null, + valid BOOL not null default true, + foreign key (member_id) references members(id) on delete cascade +); + create table if not exists member_follows ( @@ -25,9 +40,12 @@ create table if not exists member_follows created_at TIMESTAMP not null, updated_at TIMESTAMP not null , foreign key (follower_id) references members (id) on delete cascade, - foreign key (following_id) references members (id) on delete cascade + foreign key (following_id) references members (id) on delete cascade, + constraint u_follower_following unique (follower_id, following_id) ); +create index follower_following on member_follows (follower_id, following_id); + create table if not exists posts ( diff --git a/src/main/resources/static/index.html b/src/main/resources/static/index.html index 6a38c47..8ad85c4 100644 --- a/src/main/resources/static/index.html +++ b/src/main/resources/static/index.html @@ -2,12 +2,11 @@ - devSns GUI + DevSns + - - -
-
-
SNS
-

devSns

-
- + + + +
- -
- -
-
-

새 포스트

-
- - - + + -
- - -
-
-
-

피드

-
* 최신 15개씩 로드, 무한 스크롤/더보기
+

+
+
+ + +
-
- - - + + + + + + + + + -