diff --git a/.idea/dataSources.xml b/.idea/dataSources.xml index f53e927..9fd2efd 100644 --- a/.idea/dataSources.xml +++ b/.idea/dataSources.xml @@ -1,12 +1,24 @@ - + mysql.8 true true com.mysql.cj.jdbc.Driver - jdbc:mysql://localhost:3306/assignment + jdbc:mysql://serverge-rds.czgawwiias0y.ap-northeast-2.rds.amazonaws.com:3306/assignment + + + + + + $ProjectFileDir$ + + + mysql_aurora.aws_wrapper + true + software.amazon.jdbc.Driver + jdbc:aws-wrapper:mysql://localhost:3306 diff --git a/build.gradle b/build.gradle index a45202e..0d82afc 100644 --- a/build.gradle +++ b/build.gradle @@ -34,6 +34,9 @@ dependencies { // Swagger implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.3.0' + + // JWT + implementation 'com.auth0:java-jwt:4.4.0' } def querydslDir = "src/main/generated/querydsl" @@ -53,3 +56,4 @@ tasks.withType(JavaCompile).configureEach { clean { delete file(querydslDir) } + diff --git a/src/main/generated/querydsl/org/sopt/domain/member/entity/QMember.java b/src/main/generated/querydsl/org/sopt/domain/member/entity/QMember.java index f4109ff..aa6c69e 100644 --- a/src/main/generated/querydsl/org/sopt/domain/member/entity/QMember.java +++ b/src/main/generated/querydsl/org/sopt/domain/member/entity/QMember.java @@ -34,6 +34,8 @@ public class QMember extends EntityPathBase { public final StringPath name = createString("name"); + public final StringPath password = createString("password"); + public QMember(String variable) { super(Member.class, forVariable(variable)); } diff --git a/src/main/java/org/sopt/domain/article/dto/res/ArticleRes.java b/src/main/java/org/sopt/domain/article/dto/res/ArticleRes.java index e642853..81e5210 100644 --- a/src/main/java/org/sopt/domain/article/dto/res/ArticleRes.java +++ b/src/main/java/org/sopt/domain/article/dto/res/ArticleRes.java @@ -2,8 +2,10 @@ import org.sopt.domain.article.entity.Article; import org.sopt.domain.article.entity.ArticleTag; +import org.sopt.domain.comment.dto.res.CommentResponse; import java.time.LocalDateTime; +import java.util.List; public record ArticleRes( Long id, @@ -11,16 +13,18 @@ public record ArticleRes( String content, ArticleTag articleTag, LocalDateTime createdAt, - Long memberId + Long memberId, + List comments ) { - public static ArticleRes from(Article article) { + public static ArticleRes from(Article article, List comments) { return new ArticleRes( article.getId(), article.getTitle(), article.getContent(), article.getArticleTag(), article.getCreatedAt(), - article.getMember().getId() + article.getMember().getId(), + comments ); } } diff --git a/src/main/java/org/sopt/domain/article/service/ArticleServiceImpl.java b/src/main/java/org/sopt/domain/article/service/ArticleServiceImpl.java index c6be4bc..29a137a 100644 --- a/src/main/java/org/sopt/domain/article/service/ArticleServiceImpl.java +++ b/src/main/java/org/sopt/domain/article/service/ArticleServiceImpl.java @@ -7,6 +7,9 @@ import org.sopt.domain.article.dto.res.ArticleRes; import org.sopt.domain.article.entity.Article; import org.sopt.domain.article.repository.ArticleRepository; +import org.sopt.domain.comment.dto.res.CommentResponse; +import org.sopt.domain.comment.entity.Comment; +import org.sopt.domain.comment.repository.CommentRepository; import org.sopt.domain.member.entity.Member; import org.sopt.domain.member.repository.MemberRepository; import org.sopt.global.api.ErrorCode; @@ -23,6 +26,8 @@ public class ArticleServiceImpl implements ArticleService { private final ArticleRepository articleRepository; private final MemberRepository memberRepository; + private final CommentRepository commentRepository; + @Override @Transactional @@ -49,9 +54,24 @@ public void createArticle(Long userId, CreateArticleReq req) { @Override public ArticleRes getArticle(Long articleId) { - Article article = articleRepository.findById(articleId).orElseThrow(() -> new ArticleException(ErrorCode.ARTICLE_NOT_FOUND)); - return ArticleRes.from(article); + Article article = articleRepository.findById(articleId) + .orElseThrow(() -> new ArticleException(ErrorCode.ARTICLE_NOT_FOUND)); + + List comments = commentRepository + .findByArticleIdAndIsDeletedFalseOrderByCreatedAtAsc(articleId); + + List commentResponses = comments.stream() + .map(c -> new CommentResponse( + c.getId(), + c.getMember().getId(), + c.getMember().getName(), + c.getContent(), + c.getCreatedAt() + )) + .toList(); + + return ArticleRes.from(article, commentResponses); } @Override diff --git a/src/main/java/org/sopt/domain/comment/controller/CommentController.java b/src/main/java/org/sopt/domain/comment/controller/CommentController.java new file mode 100644 index 0000000..0892414 --- /dev/null +++ b/src/main/java/org/sopt/domain/comment/controller/CommentController.java @@ -0,0 +1,76 @@ +package org.sopt.domain.comment.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.sopt.domain.comment.dto.req.CommentCreateRequest; +import org.sopt.domain.comment.dto.req.CommentUpdateRequest; +import org.sopt.domain.comment.dto.res.CommentResponse; +import org.sopt.domain.comment.service.CommentServiceImpl; +import org.sopt.domain.member.repository.JwtService; +import org.sopt.global.api.ApiResponse; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@Tag(name = "Comment", description = "댓글 관련 API") +@RestController +@RequestMapping("/api/v1/comments") +@RequiredArgsConstructor +public class CommentController { + + private final CommentServiceImpl commentService; + private final JwtService jwtService; + + @Operation(summary = "댓글 생성") + @PostMapping + public ResponseEntity> create( + @RequestHeader(HttpHeaders.AUTHORIZATION) String authorization, + @Valid @RequestBody CommentCreateRequest request + ) { + Long memberId = extractMemberId(authorization); + commentService.create(memberId, request); + return ResponseEntity.ok(ApiResponse.ok(null)); + } + + @Operation(summary = "댓글 목록 조회") + @GetMapping + public ResponseEntity>> list( + @RequestParam Long articleId + ) { + return ResponseEntity.ok( + ApiResponse.ok(commentService.findByArticle(articleId)) + ); + } + + @Operation(summary = "댓글 수정") + @PatchMapping("/{commentId}") + public ResponseEntity> update( + @RequestHeader(HttpHeaders.AUTHORIZATION) String authorization, + @PathVariable Long commentId, + @Valid @RequestBody CommentUpdateRequest request + ) { + Long memberId = extractMemberId(authorization); + commentService.update(memberId, commentId, request); + return ResponseEntity.ok(ApiResponse.ok(null)); + } + + @Operation(summary = "댓글 삭제") + @DeleteMapping("/{commentId}") + public ResponseEntity> delete( + @RequestHeader(HttpHeaders.AUTHORIZATION) String authorization, + @PathVariable Long commentId + ) { + Long memberId = extractMemberId(authorization); + commentService.delete(memberId, commentId); + return ResponseEntity.ok(ApiResponse.ok(null)); + } + + private Long extractMemberId(String authorization) { + String token = authorization.replace("Bearer ", "").trim(); + return jwtService.verifyAndGetMemberId(token); + } +} diff --git a/src/main/java/org/sopt/domain/comment/dto/req/CommentCreateRequest.java b/src/main/java/org/sopt/domain/comment/dto/req/CommentCreateRequest.java new file mode 100644 index 0000000..5f9f5cb --- /dev/null +++ b/src/main/java/org/sopt/domain/comment/dto/req/CommentCreateRequest.java @@ -0,0 +1,16 @@ +package org.sopt.domain.comment.dto.req; + + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +public record CommentCreateRequest( + + @NotNull + Long articleId, + + @NotBlank + @Size(max = 300, message = "댓글 내용은 최대 300자까지 입력할 수 있습니다.") + String content +) {} diff --git a/src/main/java/org/sopt/domain/comment/dto/req/CommentUpdateRequest.java b/src/main/java/org/sopt/domain/comment/dto/req/CommentUpdateRequest.java new file mode 100644 index 0000000..7ef55bb --- /dev/null +++ b/src/main/java/org/sopt/domain/comment/dto/req/CommentUpdateRequest.java @@ -0,0 +1,12 @@ +package org.sopt.domain.comment.dto.req; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + + +public record CommentUpdateRequest( + + @NotBlank + @Size(max = 300, message = "댓글 내용은 최대 300자까지 입력할 수 있습니다.") + String content +) {} diff --git a/src/main/java/org/sopt/domain/comment/dto/res/CommentResponse.java b/src/main/java/org/sopt/domain/comment/dto/res/CommentResponse.java new file mode 100644 index 0000000..798c202 --- /dev/null +++ b/src/main/java/org/sopt/domain/comment/dto/res/CommentResponse.java @@ -0,0 +1,11 @@ +package org.sopt.domain.comment.dto.res; + +import java.time.LocalDateTime; + +public record CommentResponse( + Long commentId, + Long memberId, + String memberName, + String content, + LocalDateTime createdAt +) {} diff --git a/src/main/java/org/sopt/domain/comment/entity/Comment.java b/src/main/java/org/sopt/domain/comment/entity/Comment.java new file mode 100644 index 0000000..6279225 --- /dev/null +++ b/src/main/java/org/sopt/domain/comment/entity/Comment.java @@ -0,0 +1,59 @@ +package org.sopt.domain.comment.entity; + +import jakarta.persistence.*; +import lombok.*; +import org.sopt.domain.article.entity.Article; +import org.sopt.domain.member.entity.Member; +import org.sopt.global.entity.BaseEntity; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "comments") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class Comment extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "comment_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", nullable = false) + private Member member; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "article_id", nullable = false) + private Article article; + + @Column(nullable = false, length = 1000) + private String content; + + @Column(nullable = false) + private Boolean isDeleted; + + public static Comment create( + Member member, + Article article, + String content + ) { + return Comment.builder() + .member(member) + .article(article) + .content(content) + .isDeleted(false) + .build(); + } + + public void updateContent(String content) { + this.content = content; + this.updatedAt = LocalDateTime.now(); + } + + public void delete() { + this.isDeleted = true; + } +} diff --git a/src/main/java/org/sopt/domain/comment/repository/CommentCustomRepository.java b/src/main/java/org/sopt/domain/comment/repository/CommentCustomRepository.java new file mode 100644 index 0000000..7d34129 --- /dev/null +++ b/src/main/java/org/sopt/domain/comment/repository/CommentCustomRepository.java @@ -0,0 +1,9 @@ +package org.sopt.domain.comment.repository; + +import org.sopt.domain.comment.entity.Comment; + +import java.util.List; + +public interface CommentCustomRepository { + List findAllWithMember(); +} diff --git a/src/main/java/org/sopt/domain/comment/repository/CommentCustomRepositoryImpl.java b/src/main/java/org/sopt/domain/comment/repository/CommentCustomRepositoryImpl.java new file mode 100644 index 0000000..ae4d954 --- /dev/null +++ b/src/main/java/org/sopt/domain/comment/repository/CommentCustomRepositoryImpl.java @@ -0,0 +1,27 @@ +package org.sopt.domain.comment.repository; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.sopt.domain.comment.entity.Comment; +import org.springframework.stereotype.Repository; + +import java.util.List; + +import static org.sopt.domain.article.entity.QArticle.article; +import static org.sopt.domain.member.entity.QMember.member; + + +@Repository +@RequiredArgsConstructor +public class CommentCustomRepositoryImpl implements CommentCustomRepository { + private final JPAQueryFactory queryFactory; + + @Override + public List findAllWithMember() { + return queryFactory + .selectFrom(article) + .join(article.member, member).fetchJoin() + .fetch(); + } +} + diff --git a/src/main/java/org/sopt/domain/comment/repository/CommentRepository.java b/src/main/java/org/sopt/domain/comment/repository/CommentRepository.java new file mode 100644 index 0000000..2d67b9c --- /dev/null +++ b/src/main/java/org/sopt/domain/comment/repository/CommentRepository.java @@ -0,0 +1,10 @@ +package org.sopt.domain.comment.repository; + +import org.sopt.domain.comment.entity.Comment; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface CommentRepository extends JpaRepository { + List findByArticleIdAndIsDeletedFalseOrderByCreatedAtAsc(Long articleId); +} diff --git a/src/main/java/org/sopt/domain/comment/service/CommentService.java b/src/main/java/org/sopt/domain/comment/service/CommentService.java new file mode 100644 index 0000000..7989ddb --- /dev/null +++ b/src/main/java/org/sopt/domain/comment/service/CommentService.java @@ -0,0 +1,17 @@ +package org.sopt.domain.comment.service; + +import org.sopt.domain.comment.dto.req.CommentCreateRequest; +import org.sopt.domain.comment.dto.req.CommentUpdateRequest; +import org.sopt.domain.comment.dto.res.CommentResponse; + +import java.util.List; + +public interface CommentService { + void create(Long memberId, CommentCreateRequest request); + + List findByArticle(Long articleId); + + void update(Long memberId, Long commentId, CommentUpdateRequest request); + + void delete(Long memberId, Long commentId); +} diff --git a/src/main/java/org/sopt/domain/comment/service/CommentServiceImpl.java b/src/main/java/org/sopt/domain/comment/service/CommentServiceImpl.java new file mode 100644 index 0000000..ff4cabc --- /dev/null +++ b/src/main/java/org/sopt/domain/comment/service/CommentServiceImpl.java @@ -0,0 +1,75 @@ +package org.sopt.domain.comment.service; + +import lombok.RequiredArgsConstructor; +import org.sopt.domain.article.entity.Article; +import org.sopt.domain.article.repository.ArticleRepository; +import org.sopt.domain.comment.dto.req.CommentCreateRequest; +import org.sopt.domain.comment.dto.req.CommentUpdateRequest; +import org.sopt.domain.comment.dto.res.CommentResponse; +import org.sopt.domain.comment.entity.Comment; +import org.sopt.domain.comment.repository.CommentRepository; +import org.sopt.domain.member.entity.Member; +import org.sopt.domain.member.repository.MemberRepository; +import org.sopt.global.api.ErrorCode; +import org.sopt.global.api.GeneralException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional +public class CommentServiceImpl { + + private final CommentRepository commentRepository; + private final MemberRepository memberRepository; + private final ArticleRepository articleRepository; + + public void create(Long memberId, CommentCreateRequest request) { + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new GeneralException(ErrorCode.MEMBER_NOT_FOUND)); + + Article article = articleRepository.findById(request.articleId()) + .orElseThrow(() -> new GeneralException(ErrorCode.ARTICLE_NOT_FOUND)); + + Comment comment = Comment.create(member, article, request.content()); + commentRepository.save(comment); + } + + @Transactional(readOnly = true) + public List findByArticle(Long articleId) { + return commentRepository.findByArticleIdAndIsDeletedFalseOrderByCreatedAtAsc(articleId) + .stream() + .map(c -> new CommentResponse( + c.getId(), + c.getMember().getId(), + c.getMember().getName(), + c.getContent(), + c.getCreatedAt() + )) + .toList(); + } + + public void update(Long memberId, Long commentId, CommentUpdateRequest request) { + Comment comment = commentRepository.findById(commentId) + .orElseThrow(() -> new GeneralException(ErrorCode.COMMENT_NOT_FOUND)); + + if (!comment.getMember().getId().equals(memberId)) { + throw new GeneralException(ErrorCode.UNAUTHORIZED_COMMENT); + } + + comment.updateContent(request.content()); + } + + public void delete(Long memberId, Long commentId) { + Comment comment = commentRepository.findById(commentId) + .orElseThrow(() -> new GeneralException(ErrorCode.COMMENT_NOT_FOUND)); + + if (!comment.getMember().getId().equals(memberId)) { + throw new GeneralException(ErrorCode.UNAUTHORIZED_COMMENT); + } + + comment.delete(); + } +} diff --git a/src/main/java/org/sopt/domain/member/controller/AuthController.java b/src/main/java/org/sopt/domain/member/controller/AuthController.java new file mode 100644 index 0000000..79fc2c3 --- /dev/null +++ b/src/main/java/org/sopt/domain/member/controller/AuthController.java @@ -0,0 +1,128 @@ +package org.sopt.domain.member.controller; + +import io.swagger.v3.oas.annotations.Operation; +import jakarta.servlet.http.HttpSession; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.sopt.domain.member.dto.res.MemberResponse; +import org.sopt.domain.member.repository.JwtService; +import org.sopt.domain.member.service.AuthService; +import org.sopt.global.api.ApiResponse; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseCookie; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.time.Duration; + +@RestController +@RequiredArgsConstructor +@Slf4j +public class AuthController { + + private final AuthService authService; + private final JwtService jwtService; + + @Operation(summary = "헤더 기반 Basic-Authentication") + @PostMapping("/v1/login") + public ResponseEntity> login( + @RequestHeader(value = HttpHeaders.AUTHORIZATION, required = false) String authorization + ) { + log.info(authorization); + MemberResponse result = authService.login(authorization); + return ResponseEntity.ok(ApiResponse.ok(result)); + } + + @Operation(summary = "이메일/비밀번호 기반 로그인 (쿠키 발급)") + @PostMapping("/v2/login") + public ResponseEntity> loginV2( + @RequestParam("email") String email, + @RequestParam("password") String password + ) { + MemberResponse result = authService.loginWithCredentials(email, password); + + String credentials = email + ":" + password; + + ResponseCookie cookie = ResponseCookie.from("basic", credentials) + .httpOnly(true) + .secure(true) + .sameSite("Lax") + .maxAge(Duration.ofHours(1)) + .path("/") + .build(); + + return ResponseEntity.ok() + .header(HttpHeaders.SET_COOKIE, cookie.toString()) + .body(ApiResponse.ok(result)); + } + + @Operation(summary = "쿠키로 로그인 여부 확인") + @GetMapping("/v2/me") + public ResponseEntity> checkSession( + @CookieValue(value = "basic", required = false) String basicCookie + ) { + if (basicCookie == null) { + throw new IllegalArgumentException("로그인 쿠키가 없습니다."); + } + + int idx = basicCookie.indexOf(":"); + if (idx < 0) { + throw new IllegalArgumentException("쿠키에 이메일과 비밀번호 구분자(:)가 없습니다."); + } + String email = basicCookie.substring(0, idx); + String password = basicCookie.substring(idx + 1); + + log.info("[v2] 세션 확인, 이메일: {}", email); + MemberResponse result = authService.loginWithCredentials(email, password); + return ResponseEntity.ok(ApiResponse.ok(result)); + } + + @Operation(summary = "세션 기반 로그인 (JSESSIONID 발급)") + @PostMapping("/v3/login") + public ResponseEntity> loginV3( + @RequestParam("email") String email, + @RequestParam("password") String password, + HttpSession session + ) { + MemberResponse result = authService.loginWithCredentials(email, password); + session.setAttribute("LOGIN_MEMBER_ID", result.id()); + return ResponseEntity.ok(ApiResponse.ok(result)); + } + + @Operation(summary = "세션 로그인 여부 확인") + @GetMapping("/v3/me") + public ResponseEntity> checkSessionV3( + HttpSession session + ) { + Long memberId = (Long) session.getAttribute("LOGIN_MEMBER_ID"); + MemberResponse result = authService.getMemberById(memberId); + return ResponseEntity.ok(ApiResponse.ok(result)); + } + + @Operation(summary = "JWT 기반 로그인 (토큰 반환)") + @PostMapping("/v4/login") + public ResponseEntity> loginV4( + @RequestParam("email") String email, + @RequestParam("password") String password + ) { + MemberResponse member = authService.loginWithCredentials(email, password); + String token = jwtService.generateToken(member.id(), member.email()); + return ResponseEntity.ok(ApiResponse.ok(token)); + } + + @Operation(summary = "JWT 검증 (Authorization: Bearer)") + @GetMapping("/v4/me") + public ResponseEntity> checkSessionV4( + @RequestHeader(value = HttpHeaders.AUTHORIZATION, required = false) String authorization + ) { + String raw = null; + if (authorization != null && authorization.startsWith("Bearer ")) { + raw = authorization.substring("Bearer ".length()).trim(); + } + + Long memberId = jwtService.verifyAndGetMemberId(raw); + MemberResponse member = authService.getMemberById(memberId); + return ResponseEntity.ok(ApiResponse.ok(member)); + } +} + diff --git a/src/main/java/org/sopt/domain/member/dto/req/CreateMemberReq.java b/src/main/java/org/sopt/domain/member/dto/req/CreateMemberReq.java index 3133f16..a0ec02c 100644 --- a/src/main/java/org/sopt/domain/member/dto/req/CreateMemberReq.java +++ b/src/main/java/org/sopt/domain/member/dto/req/CreateMemberReq.java @@ -8,6 +8,7 @@ public record CreateMemberReq( String name, LocalDate birthday, String email, - Gender gender + Gender gender, + String password ) { } diff --git a/src/main/java/org/sopt/domain/member/dto/res/MemberResponse.java b/src/main/java/org/sopt/domain/member/dto/res/MemberResponse.java new file mode 100644 index 0000000..590727b --- /dev/null +++ b/src/main/java/org/sopt/domain/member/dto/res/MemberResponse.java @@ -0,0 +1,28 @@ +package org.sopt.domain.member.dto.res; + +import org.sopt.domain.member.entity.Gender; +import org.sopt.domain.member.entity.Member; + +import java.time.LocalDate; + +public record MemberResponse( + Long id, + String name, + Gender gender, + LocalDate birthDate, + String email +) { + public static MemberResponse from(Member m) { + return new MemberResponse( + m.getId(), + m.getName(), + m.getGender(), + m.getBirthday(), + m.getEmail() + ); + } + + public static MemberResponse of(Long id, String name, Gender gender, LocalDate birthDate, String email) { + return new MemberResponse(id, name, gender, birthDate, email); + } +} diff --git a/src/main/java/org/sopt/domain/member/entity/Member.java b/src/main/java/org/sopt/domain/member/entity/Member.java index 590d820..239a0b9 100644 --- a/src/main/java/org/sopt/domain/member/entity/Member.java +++ b/src/main/java/org/sopt/domain/member/entity/Member.java @@ -33,6 +33,7 @@ public class Member{ private String name; private LocalDate birthday; private String email; + private String password; @Enumerated(EnumType.STRING) private Gender gender; @@ -41,20 +42,23 @@ public class Member{ @JsonIgnore private boolean isDeleted = Boolean.FALSE; - public Member(Long id, String name, LocalDate birthday, String email, Gender gender) { + public Member(Long id, String name, LocalDate birthday, String email, Gender gender, String password) { this.id = id; this.name = name; this.birthday = birthday; this.email = email; this.gender = gender; + this.password = password; + } - public static Member of(String name, LocalDate birthday, String email, Gender gender) { + public static Member of(String name, LocalDate birthday, String email, Gender gender, String password) { return Member.builder() .name(name) .birthday(birthday) .email(email) .gender(gender) + .password(password) .build(); } } diff --git a/src/main/java/org/sopt/domain/member/repository/JwtService.java b/src/main/java/org/sopt/domain/member/repository/JwtService.java new file mode 100644 index 0000000..99fdbe4 --- /dev/null +++ b/src/main/java/org/sopt/domain/member/repository/JwtService.java @@ -0,0 +1,54 @@ +package org.sopt.domain.member.repository; + +import com.auth0.jwt.JWT; +import com.auth0.jwt.algorithms.Algorithm; +import com.auth0.jwt.interfaces.DecodedJWT; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.util.Date; + +@Service +public class JwtService { + + private final Algorithm algorithm; + private final long defaultExpiresInSeconds; + + public JwtService( + @Value("${security.jwt.secret}") String secret, + @Value("${security.jwt.expires-in-seconds:3600}") long defaultExpiresInSeconds + ) { + this.algorithm = Algorithm.HMAC256(secret); + this.defaultExpiresInSeconds = defaultExpiresInSeconds; + } + + public String generateToken(Long memberId, String email) { + return generateToken(memberId, email, defaultExpiresInSeconds); + } + + public String generateToken(Long memberId, String email, long expiresInSeconds) { + Instant now = Instant.now(); + Instant exp = now.plusSeconds(expiresInSeconds); + + return JWT.create() + .withSubject(String.valueOf(memberId)) + .withClaim("email", email) + .withIssuedAt(Date.from(now)) + .withExpiresAt(Date.from(exp)) + .sign(algorithm); + } + + public Long verifyAndGetMemberId(String token) { + if (token == null || token.isBlank()) { + throw new IllegalArgumentException("토큰이 없습니다."); + } + DecodedJWT jwt = JWT.require(algorithm).build().verify(token); + String sub = jwt.getSubject(); + try { + return Long.parseLong(sub); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("JWT의 회원 정보가 올바르지 않습니다."); + } + } +} diff --git a/src/main/java/org/sopt/domain/member/repository/MemberRepository.java b/src/main/java/org/sopt/domain/member/repository/MemberRepository.java index 76e6ea0..c9c6002 100644 --- a/src/main/java/org/sopt/domain/member/repository/MemberRepository.java +++ b/src/main/java/org/sopt/domain/member/repository/MemberRepository.java @@ -7,4 +7,6 @@ public interface MemberRepository extends JpaRepository { boolean existsByEmailAndIsDeletedFalse(String email); + + Optional findByEmail(String email); } diff --git a/src/main/java/org/sopt/domain/member/service/AuthService.java b/src/main/java/org/sopt/domain/member/service/AuthService.java new file mode 100644 index 0000000..25e5732 --- /dev/null +++ b/src/main/java/org/sopt/domain/member/service/AuthService.java @@ -0,0 +1,79 @@ +package org.sopt.domain.member.service; + +import lombok.RequiredArgsConstructor; +import org.sopt.domain.member.dto.res.MemberResponse; +import org.sopt.domain.member.entity.Member; +import org.sopt.domain.member.repository.MemberRepository; +import org.springframework.stereotype.Service; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +@Service +@RequiredArgsConstructor +public class AuthService { + + private final MemberRepository memberRepository; + + public MemberResponse login(String authorizationHeader) { + if (authorizationHeader == null || authorizationHeader.isBlank()) { + throw new IllegalArgumentException("Authorization 헤더가 없습니다."); + } + + if (!authorizationHeader.startsWith("Basic ")) { + throw new IllegalArgumentException("Basic 인증 형식이 아닙니다."); + } + + String token = authorizationHeader.substring("Basic ".length()).trim(); + String decoded; + try { + decoded = new String(Base64.getDecoder().decode(token), StandardCharsets.UTF_8); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("Authorization 헤더가 Base64 형식이 아닙니다."); + } + + int idx = decoded.indexOf(":"); + if (idx < 0) { + throw new IllegalArgumentException("이메일과 비밀번호 구분자(:)가 없습니다."); + } + + String email = decoded.substring(0, idx); + String password = decoded.substring(idx + 1); + + Member member = memberRepository.findByEmail(email) + .orElseThrow(() -> new IllegalArgumentException("회원이 존재하지 않습니다.")); + + if (member.getPassword() == null || !member.getPassword().equals(password)) { + throw new IllegalArgumentException("이메일 또는 비밀번호가 올바르지 않습니다."); + } + + return MemberResponse.from(member); + } + + public MemberResponse loginWithCredentials(String email, String password) { + if (email == null || email.isBlank() || password == null || + password.isBlank()) { + throw new IllegalArgumentException("이메일과 비밀번호를 모두 입력해주세요."); + } + + Member member = memberRepository.findByEmail(email) + .orElseThrow(() -> new IllegalArgumentException("회원이 존재하지 않습니다.")); + + if (member.getPassword() == null || ! + member.getPassword().equals(password)) { + throw new IllegalArgumentException("이메일 또는 비밀번호가 올바르지 않습니다."); + } + + return MemberResponse.from(member); + + } + + public MemberResponse getMemberById(Long memberId) { + if (memberId == null) { + throw new IllegalArgumentException("로그인되어 있지 않습니다."); + } + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new IllegalArgumentException("회원이 존재하지 않습니다.")); + return MemberResponse.from(member); + } +} diff --git a/src/main/java/org/sopt/domain/member/validator/MemberValidator.java b/src/main/java/org/sopt/domain/member/validator/MemberValidator.java index d219fcb..85f4a3f 100644 --- a/src/main/java/org/sopt/domain/member/validator/MemberValidator.java +++ b/src/main/java/org/sopt/domain/member/validator/MemberValidator.java @@ -19,7 +19,8 @@ public Member createValidatedMember(CreateMemberReq req) { req.name(), req.birthday(), req.email(), - req.gender() + req.gender(), + req.password() ); } diff --git a/src/main/java/org/sopt/global/api/ErrorCode.java b/src/main/java/org/sopt/global/api/ErrorCode.java index 52b7d8d..b8314b4 100644 --- a/src/main/java/org/sopt/global/api/ErrorCode.java +++ b/src/main/java/org/sopt/global/api/ErrorCode.java @@ -18,6 +18,13 @@ public enum ErrorCode { DUPLICATE_ARTICLE_TITLE(HttpStatus.BAD_REQUEST, 400, "이미 존재하는 아티클 제목입니다."), + /** + * 401 UNAUTHORIZED + */ + // 댓글 관련 + UNAUTHORIZED_COMMENT(HttpStatus.UNAUTHORIZED, 401, "댓글 수정 권한이 없습니다."), + + /** * 404 NOT FOUND */ @@ -27,6 +34,9 @@ public enum ErrorCode { // 아티클 관련 ARTICLE_NOT_FOUND(HttpStatus.NOT_FOUND, 404, "아티클을 찾을 수 없습니다."), + // 댓글 관련 + COMMENT_NOT_FOUND(HttpStatus.NOT_FOUND, 404, "댓글을 찾을 수 없습니다."), + /** * 500 INTERNAL SERVER ERROR * */ diff --git a/src/main/java/org/sopt/global/entity/BaseEntity.java b/src/main/java/org/sopt/global/entity/BaseEntity.java new file mode 100644 index 0000000..6d61a33 --- /dev/null +++ b/src/main/java/org/sopt/global/entity/BaseEntity.java @@ -0,0 +1,28 @@ +package org.sopt.global.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@EntityListeners(AuditingEntityListener.class) +@Getter +@MappedSuperclass +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public abstract class BaseEntity { + + @CreatedDate + @Column(updatable = false) + protected LocalDateTime createdAt; + + @LastModifiedDate + protected LocalDateTime updatedAt; + +}