diff --git a/build.gradle b/build.gradle index 6f407f6..b2890b9 100644 --- a/build.gradle +++ b/build.gradle @@ -56,6 +56,9 @@ dependencies { implementation 'io.jsonwebtoken:jjwt-api:0.11.5' runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' + + // Mail + implementation 'org.springframework.boot:spring-boot-starter-mail' } tasks.named('test') { diff --git a/docker-compose.yml b/docker-compose.yml index 3e66eab..d5f64a5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,7 +2,7 @@ services: app: build: . ports: - - "8080:8080" + - "8081:8080" env_file: - .env depends_on: diff --git a/src/main/java/UMC/news/newsIntelligent/domain/dailyReport/DailyReport.java b/src/main/java/UMC/news/newsIntelligent/domain/dailyReport/DailyReport.java index d1825d9..6af9592 100644 --- a/src/main/java/UMC/news/newsIntelligent/domain/dailyReport/DailyReport.java +++ b/src/main/java/UMC/news/newsIntelligent/domain/dailyReport/DailyReport.java @@ -1,6 +1,6 @@ package UMC.news.newsIntelligent.domain.dailyReport; -import UMC.news.newsIntelligent.domain.member.Member; +import UMC.news.newsIntelligent.domain.member.entity.Member; import UMC.news.newsIntelligent.global.entity.BaseEntity; import jakarta.persistence.Column; import jakarta.persistence.Entity; diff --git a/src/main/java/UMC/news/newsIntelligent/domain/feedback/entity/Feedback.java b/src/main/java/UMC/news/newsIntelligent/domain/feedback/entity/Feedback.java index f38690b..12cbfaa 100644 --- a/src/main/java/UMC/news/newsIntelligent/domain/feedback/entity/Feedback.java +++ b/src/main/java/UMC/news/newsIntelligent/domain/feedback/entity/Feedback.java @@ -1,6 +1,6 @@ package UMC.news.newsIntelligent.domain.feedback.entity; -import UMC.news.newsIntelligent.domain.member.Member; +import UMC.news.newsIntelligent.domain.member.entity.Member; import UMC.news.newsIntelligent.global.entity.BaseEntity; import jakarta.persistence.Column; import jakarta.persistence.Entity; diff --git a/src/main/java/UMC/news/newsIntelligent/domain/mail/dto/EmailRequestDto.java b/src/main/java/UMC/news/newsIntelligent/domain/mail/dto/EmailRequestDto.java new file mode 100644 index 0000000..dd62308 --- /dev/null +++ b/src/main/java/UMC/news/newsIntelligent/domain/mail/dto/EmailRequestDto.java @@ -0,0 +1,6 @@ +package UMC.news.newsIntelligent.domain.mail.dto; + +import jakarta.validation.constraints.Email; + +public record EmailRequestDto(@Email String email) { +} diff --git a/src/main/java/UMC/news/newsIntelligent/domain/mail/dto/VerifyRequestDto.java b/src/main/java/UMC/news/newsIntelligent/domain/mail/dto/VerifyRequestDto.java new file mode 100644 index 0000000..d7b7be2 --- /dev/null +++ b/src/main/java/UMC/news/newsIntelligent/domain/mail/dto/VerifyRequestDto.java @@ -0,0 +1,8 @@ +package UMC.news.newsIntelligent.domain.mail.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.Size; + +public record VerifyRequestDto(@Email String email, + @Size(min = 6, max = 6) String code) { +} diff --git a/src/main/java/UMC/news/newsIntelligent/domain/mail/entity/OtpCode.java b/src/main/java/UMC/news/newsIntelligent/domain/mail/entity/OtpCode.java new file mode 100644 index 0000000..d00dbf4 --- /dev/null +++ b/src/main/java/UMC/news/newsIntelligent/domain/mail/entity/OtpCode.java @@ -0,0 +1,51 @@ +package UMC.news.newsIntelligent.domain.mail.entity; + +import UMC.news.newsIntelligent.global.apiPayload.code.error.GeneralErrorCode; +import UMC.news.newsIntelligent.global.apiPayload.exception.CustomException; +import jakarta.persistence.*; +import lombok.*; + +import java.io.Serializable; +import java.time.LocalDateTime; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +@IdClass(OtpCode.PK.class) +public class OtpCode { + + @Id + private String email; + @Id @Enumerated(EnumType.STRING) + private Type type; // SIGNUP, LOGIN + private String code; // 6자리 인증 코드 + private String token; // 매직링크용 토큰 + private LocalDateTime expiresAt; + + /** 완료 플래그: + * otp code나 매직링크 둘 중 하나로 검증되면 true + * → 재사용 방지 */ + @Column(nullable = false) @Builder.Default + private Boolean verified = false; + + public enum Type { SIGNUP, LOGIN; } + + @Getter @Setter + @NoArgsConstructor @AllArgsConstructor + public static class PK implements Serializable { + private String email; + private Type type; + } + + public void validateUsable() { + if (Boolean.TRUE.equals(verified)) + throw new CustomException(GeneralErrorCode.OTP_WRONG); // 불일치 + if (expiresAt.isBefore(LocalDateTime.now())) + throw new CustomException(GeneralErrorCode.OTP_EXPIRED); // 만료 + } + + public void markVerified() { this.verified = true; } + +} diff --git a/src/main/java/UMC/news/newsIntelligent/domain/mail/repository/OtpCodeRepository.java b/src/main/java/UMC/news/newsIntelligent/domain/mail/repository/OtpCodeRepository.java new file mode 100644 index 0000000..9e321a4 --- /dev/null +++ b/src/main/java/UMC/news/newsIntelligent/domain/mail/repository/OtpCodeRepository.java @@ -0,0 +1,10 @@ +package UMC.news.newsIntelligent.domain.mail.repository; + +import UMC.news.newsIntelligent.domain.mail.entity.OtpCode; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface OtpCodeRepository extends JpaRepository { + Optional findByToken(String token); +} \ No newline at end of file diff --git a/src/main/java/UMC/news/newsIntelligent/domain/mail/service/MailService.java b/src/main/java/UMC/news/newsIntelligent/domain/mail/service/MailService.java new file mode 100644 index 0000000..4a90ed6 --- /dev/null +++ b/src/main/java/UMC/news/newsIntelligent/domain/mail/service/MailService.java @@ -0,0 +1,54 @@ +package UMC.news.newsIntelligent.domain.mail.service; + +import UMC.news.newsIntelligent.domain.mail.entity.OtpCode; +import jakarta.mail.Message; +import jakarta.mail.internet.InternetAddress; +import jakarta.mail.internet.MimeMessage; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.stereotype.Service; +import java.security.SecureRandom; +import java.util.UUID; + +@Slf4j +@Service +@RequiredArgsConstructor +public class MailService { + + private final JavaMailSender mailSender; + @Value("${spring.mail.username}") private String from; + + public String generateCode() { + return String.format("%06d", new SecureRandom().nextInt(1_000_000)); + } + public String generateToken() { + return UUID.randomUUID().toString().replace("-", ""); // 32 hex + } + + public void sendOtpMail(String to, String code, String token, OtpCode.Type type) { + String path = (type == OtpCode.Type.SIGNUP) ? "signup" : "login"; + String link = "https://newsintelligent.io/%s/magic?token=%s".formatted(path, token); + + String html = """ +
+

NewsIntelligent 인증 메일입니다.

+

아래 코드를 입력하거나 매직링크를 클릭하세요.

+

%s

+

➡️ 매직링크 바로가기 · 5분 내 1회 사용

+
+ """.formatted(code, link); + + try { + MimeMessage msg = mailSender.createMimeMessage(); + msg.addRecipients(Message.RecipientType.TO, to); + msg.setSubject("[NewsIntelligent] 인증 메일"); + msg.setText(html, "utf-8", "html"); + msg.setFrom(new InternetAddress(from, "NewsIntelligent")); + mailSender.send(msg); + } catch (Exception e) { + throw new IllegalStateException("메일 발송 실패", e); + } + } +} \ No newline at end of file diff --git a/src/main/java/UMC/news/newsIntelligent/domain/member/controller/AuthController.java b/src/main/java/UMC/news/newsIntelligent/domain/member/controller/AuthController.java new file mode 100644 index 0000000..09d4d7b --- /dev/null +++ b/src/main/java/UMC/news/newsIntelligent/domain/member/controller/AuthController.java @@ -0,0 +1,101 @@ +package UMC.news.newsIntelligent.domain.member.controller; + +import UMC.news.newsIntelligent.domain.mail.dto.EmailRequestDto; +import UMC.news.newsIntelligent.domain.mail.dto.VerifyRequestDto; +import UMC.news.newsIntelligent.domain.mail.entity.OtpCode; +import UMC.news.newsIntelligent.domain.member.dto.MemberResponseDto; +import UMC.news.newsIntelligent.domain.member.dto.TokenResponseDto; +import UMC.news.newsIntelligent.domain.member.entity.Member; +import UMC.news.newsIntelligent.domain.member.repository.MemberRepository; +import UMC.news.newsIntelligent.domain.member.service.AuthService; +import UMC.news.newsIntelligent.domain.member.service.MemberService; +import UMC.news.newsIntelligent.global.apiPayload.CustomResponse; +import UMC.news.newsIntelligent.global.apiPayload.code.success.GeneralSuccessCode; +import UMC.news.newsIntelligent.global.config.security.jwt.JwtTokenProvider; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.view.RedirectView; + +@Slf4j +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/members") +@Tag(name="사용자 및 인증 관련 API", description = "사용자 인증 및 가입/로그인/로그아웃/탈퇴") +public class AuthController { + + private final AuthService auth; + private final MemberService memberService; + private final MemberRepository memberRepository; + + /* --- 메일 발송 --- */ + @Operation(summary = "회원가입 인증번호 전송", description = "회원가입 시 사용자에게 이메일로 인증번호를 전송하는 API입니다.") + @PostMapping("/signup/email") + public CustomResponse signupEmail(@RequestBody EmailRequestDto request) { + auth.sendCode(request.email(), OtpCode.Type.SIGNUP); + return CustomResponse.onSuccess(GeneralSuccessCode.EMAIL_SENT); + } + + @Operation(summary = "로그인 인증번호 전송", description = "로그인 시 사용자에게 이메일로 인증번호를 전송하는 API입니다.") + @PostMapping("/login/email") + public CustomResponse loginEmail(@RequestBody EmailRequestDto request) { + auth.sendCode(request.email(), OtpCode.Type.LOGIN); + return CustomResponse.onSuccess(GeneralSuccessCode.EMAIL_SENT); + } + + /* --- 인증 코드 검증 --- */ + @Operation(summary = "회원가입 인증코드 검증", description = "회원가입 시 전송된 6자리 코드를 검증하는 API입니다.") + @PostMapping("/signup/verify") + public CustomResponse signupVerify(@RequestBody VerifyRequestDto request) { + MemberResponseDto responseDto = MemberResponseDto.from(auth.signupByCode(request.email(), request.code())); + return CustomResponse.onSuccess(GeneralSuccessCode.SIGNUP_SUCCESS, responseDto); + } + @Operation(summary = "로그인 인증코드 검증", description = "로그인 시 전송된 6자리 코드를 검증하는 API입니다.") + @PostMapping("/login/verify") + public CustomResponse loginVerify(@RequestBody VerifyRequestDto request) { + TokenResponseDto responseDto = auth.loginByCode(request.email(), request.code()); + return CustomResponse.onSuccess(GeneralSuccessCode.LOGIN_SUCCESS, responseDto); + } + + /* --- 로그아웃 --- */ + @Operation(summary = "로그아웃", description = "액세스 토큰을 무효화하는 API입니다.") + @PostMapping("/logout") + public CustomResponse logout(HttpServletRequest request, + @AuthenticationPrincipal UserDetails userDetails) { + String token = JwtTokenProvider.resolveToken(request); + memberService.logout(token); + return CustomResponse.onSuccess(GeneralSuccessCode.LOGOUT_SUCCESS); + } + + /* --- 회원 탈퇴 --- */ + @Operation(summary = "회원 탈퇴", description = "회원 탈퇴 후 액세스 토큰을 무효화하는 API입니다.") + @DeleteMapping("/withdraw") + public CustomResponse withdraw(HttpServletRequest request, + @AuthenticationPrincipal UserDetails userDetails) { + String token = JwtTokenProvider.resolveToken(request); + Member member = memberRepository.findByEmail(userDetails.getUsername()) + .orElseThrow(); + memberService.withdraw(member, token); + return CustomResponse.onSuccess(GeneralSuccessCode.WITHDRAW_SUCCESS); + } + + + /* --- 매직 링크 --- */ + @Operation(summary = "리다이렉트용", description = "프론트엔드에서 구현 필요 X") + @GetMapping("/signup/magic") + public RedirectView signupMagic(@RequestParam String token) { + auth.signupByToken(token); + return new RedirectView("/signup/success"); + } + @Operation(summary = "리다이렉트용", description = "프론트엔드에서 구현 필요 X") + @GetMapping("/login/magic") + public RedirectView loginMagic(@RequestParam String token) { + TokenResponseDto tr = auth.loginByToken(token); + return new RedirectView("/login/magic-success#" + tr.accessToken()); + } +} \ No newline at end of file diff --git a/src/main/java/UMC/news/newsIntelligent/domain/member/controller/MemberController.java b/src/main/java/UMC/news/newsIntelligent/domain/member/controller/MemberController.java new file mode 100644 index 0000000..cf20a44 --- /dev/null +++ b/src/main/java/UMC/news/newsIntelligent/domain/member/controller/MemberController.java @@ -0,0 +1,36 @@ +package UMC.news.newsIntelligent.domain.member.controller; + +import UMC.news.newsIntelligent.domain.member.dto.MemberInfoDto; +import UMC.news.newsIntelligent.domain.member.service.MemberQueryService; +import UMC.news.newsIntelligent.global.apiPayload.CustomResponse; +import UMC.news.newsIntelligent.global.apiPayload.code.success.GeneralSuccessCode; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/members") +@Tag(name="회원 관련 API", description = "회원 정보 조회/수정") +public class MemberController { + private final MemberQueryService memberQueryService; + + /* --- 회원 정보 조회 --- */ + @Operation(summary = "회원 정보 조회", description = "회원정보를 리스트로 반환하는 API입니다.") + @GetMapping("/info/{memberId}") + public CustomResponse> getInfo(@PathVariable Long memberId) { + + MemberInfoDto dto = memberQueryService.getInfo(memberId); + + return CustomResponse.onSuccess( + GeneralSuccessCode.OK, + List.of(dto) + ); + } +} diff --git a/src/main/java/UMC/news/newsIntelligent/domain/member/converter/MemberInfoConverter.java b/src/main/java/UMC/news/newsIntelligent/domain/member/converter/MemberInfoConverter.java new file mode 100644 index 0000000..6ad2334 --- /dev/null +++ b/src/main/java/UMC/news/newsIntelligent/domain/member/converter/MemberInfoConverter.java @@ -0,0 +1,30 @@ +package UMC.news.newsIntelligent.domain.member.converter; + +import UMC.news.newsIntelligent.domain.member.dto.MemberInfoDto; +import UMC.news.newsIntelligent.domain.member.entity.Member; + +import java.util.List; +import java.util.stream.Collectors; + +public class MemberInfoConverter { + private MemberInfoConverter() {} // 인스턴스화 방지 + + public static MemberInfoDto toDto(Member member) { + return new MemberInfoDto( + member.getEmail(), + member.getSubscribeTopicAlert(), + member.getReadTopicAlert(), + member.getDailyReportAlert(), + member.getCreatedAt(), + member.getUpdatedAt(), + member.getIsDeactivated() + ); + } + + /* entity -> dto */ + public static List toDtoList(List members) { + return members.stream() + .map(MemberInfoConverter::toDto) + .collect(Collectors.toList()); + } +} diff --git a/src/main/java/UMC/news/newsIntelligent/domain/member/dto/MemberInfoDto.java b/src/main/java/UMC/news/newsIntelligent/domain/member/dto/MemberInfoDto.java new file mode 100644 index 0000000..ddd4015 --- /dev/null +++ b/src/main/java/UMC/news/newsIntelligent/domain/member/dto/MemberInfoDto.java @@ -0,0 +1,13 @@ +package UMC.news.newsIntelligent.domain.member.dto; + +import java.time.LocalDateTime; + +public record MemberInfoDto( + String email, + Boolean subscribe_topic_alert, + Boolean read_topic_alert, + Boolean daily_report_alert, + LocalDateTime createdAt, + LocalDateTime updatedAt, + Boolean is_deactivated +) {} \ No newline at end of file diff --git a/src/main/java/UMC/news/newsIntelligent/domain/member/dto/MemberResponseDto.java b/src/main/java/UMC/news/newsIntelligent/domain/member/dto/MemberResponseDto.java new file mode 100644 index 0000000..e82a15d --- /dev/null +++ b/src/main/java/UMC/news/newsIntelligent/domain/member/dto/MemberResponseDto.java @@ -0,0 +1,9 @@ +package UMC.news.newsIntelligent.domain.member.dto; + +import UMC.news.newsIntelligent.domain.member.entity.Member; + +public record MemberResponseDto(Long id, String email) { + public static MemberResponseDto from(Member member) { + return new MemberResponseDto(member.getId(), member.getEmail()); + } +} diff --git a/src/main/java/UMC/news/newsIntelligent/domain/member/dto/TokenResponseDto.java b/src/main/java/UMC/news/newsIntelligent/domain/member/dto/TokenResponseDto.java new file mode 100644 index 0000000..556292f --- /dev/null +++ b/src/main/java/UMC/news/newsIntelligent/domain/member/dto/TokenResponseDto.java @@ -0,0 +1,4 @@ +package UMC.news.newsIntelligent.domain.member.dto; + +public record TokenResponseDto(String accessToken) { +} diff --git a/src/main/java/UMC/news/newsIntelligent/domain/member/Member.java b/src/main/java/UMC/news/newsIntelligent/domain/member/entity/Member.java similarity index 68% rename from src/main/java/UMC/news/newsIntelligent/domain/member/Member.java rename to src/main/java/UMC/news/newsIntelligent/domain/member/entity/Member.java index 30492d7..982638b 100644 --- a/src/main/java/UMC/news/newsIntelligent/domain/member/Member.java +++ b/src/main/java/UMC/news/newsIntelligent/domain/member/entity/Member.java @@ -1,5 +1,6 @@ -package UMC.news.newsIntelligent.domain.member; +package UMC.news.newsIntelligent.domain.member.entity; +import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; @@ -13,24 +14,25 @@ import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.OneToMany; -import lombok.AccessLevel; -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.NoArgsConstructor; +import lombok.*; @Entity @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor +@Builder public class Member extends BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - @Column(nullable = false) + @Column(nullable = false, unique = true) private String email; + // 마지막 로그인 시각 (OTP 갱신용) + private LocalDateTime lastLoginAt; + // 사용자 알림 여부 @Column(name = "subscribe_topic_alert", nullable = false) private Boolean subscribeTopicAlert; @@ -58,4 +60,26 @@ public class Member extends BaseEntity { cascade = CascadeType.ALL, orphanRemoval = true) private List memberTopics = new ArrayList<>(); + + + public static Member newMember(String email) { + return Member.builder() + .email(email) + .subscribeTopicAlert(true) + .readTopicAlert(true) + .dailyReportAlert(true) + .isDeactivated(false) + .build(); + } + + public void updateLastLogin() { + this.lastLoginAt = LocalDateTime.now(); + } + + public void deactivate() { + this.isDeactivated = true; + } + public boolean isDeactivated() { + return Boolean.TRUE.equals(isDeactivated); + } } diff --git a/src/main/java/UMC/news/newsIntelligent/domain/member/MemberTopic.java b/src/main/java/UMC/news/newsIntelligent/domain/member/entity/MemberTopic.java similarity index 94% rename from src/main/java/UMC/news/newsIntelligent/domain/member/MemberTopic.java rename to src/main/java/UMC/news/newsIntelligent/domain/member/entity/MemberTopic.java index 4cce6f0..3f1011c 100644 --- a/src/main/java/UMC/news/newsIntelligent/domain/member/MemberTopic.java +++ b/src/main/java/UMC/news/newsIntelligent/domain/member/entity/MemberTopic.java @@ -1,4 +1,4 @@ -package UMC.news.newsIntelligent.domain.member; +package UMC.news.newsIntelligent.domain.member.entity; import UMC.news.newsIntelligent.domain.topic.Topic; import UMC.news.newsIntelligent.global.entity.BaseEntity; diff --git a/src/main/java/UMC/news/newsIntelligent/domain/member/entity/RevokedToken.java b/src/main/java/UMC/news/newsIntelligent/domain/member/entity/RevokedToken.java new file mode 100644 index 0000000..55ecab9 --- /dev/null +++ b/src/main/java/UMC/news/newsIntelligent/domain/member/entity/RevokedToken.java @@ -0,0 +1,20 @@ +package UMC.news.newsIntelligent.domain.member.entity; + +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Entity +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class RevokedToken { + + @Id + private String jwtId; + private LocalDateTime expiresAt; // accessToken 만료 시각 +} diff --git a/src/main/java/UMC/news/newsIntelligent/domain/member/repository/MemberRepository.java b/src/main/java/UMC/news/newsIntelligent/domain/member/repository/MemberRepository.java new file mode 100644 index 0000000..d190143 --- /dev/null +++ b/src/main/java/UMC/news/newsIntelligent/domain/member/repository/MemberRepository.java @@ -0,0 +1,11 @@ +package UMC.news.newsIntelligent.domain.member.repository; + +import UMC.news.newsIntelligent.domain.member.entity.Member; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface MemberRepository extends JpaRepository { + boolean existsByEmail(String email); + Optional findByEmail(String email); +} \ No newline at end of file diff --git a/src/main/java/UMC/news/newsIntelligent/domain/member/repository/RevokedTokenRepository.java b/src/main/java/UMC/news/newsIntelligent/domain/member/repository/RevokedTokenRepository.java new file mode 100644 index 0000000..bf1fa48 --- /dev/null +++ b/src/main/java/UMC/news/newsIntelligent/domain/member/repository/RevokedTokenRepository.java @@ -0,0 +1,6 @@ +package UMC.news.newsIntelligent.domain.member.repository; + +import UMC.news.newsIntelligent.domain.member.entity.RevokedToken; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface RevokedTokenRepository extends JpaRepository { } diff --git a/src/main/java/UMC/news/newsIntelligent/domain/member/service/AuthService.java b/src/main/java/UMC/news/newsIntelligent/domain/member/service/AuthService.java new file mode 100644 index 0000000..cdeebe3 --- /dev/null +++ b/src/main/java/UMC/news/newsIntelligent/domain/member/service/AuthService.java @@ -0,0 +1,120 @@ +package UMC.news.newsIntelligent.domain.member.service; + +import UMC.news.newsIntelligent.domain.mail.entity.OtpCode; +import UMC.news.newsIntelligent.domain.mail.repository.OtpCodeRepository; +import UMC.news.newsIntelligent.domain.mail.service.MailService; +import UMC.news.newsIntelligent.domain.member.entity.Member; +import UMC.news.newsIntelligent.domain.member.repository.MemberRepository; +import UMC.news.newsIntelligent.domain.member.dto.TokenResponseDto; +import UMC.news.newsIntelligent.global.apiPayload.code.error.GeneralErrorCode; +import UMC.news.newsIntelligent.global.apiPayload.exception.CustomException; +import UMC.news.newsIntelligent.global.config.security.jwt.JwtTokenProvider; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; + +import static UMC.news.newsIntelligent.domain.mail.entity.OtpCode.Type.LOGIN; +import static UMC.news.newsIntelligent.domain.mail.entity.OtpCode.Type.SIGNUP; + +@Service +@Transactional +@RequiredArgsConstructor +public class AuthService { + + private final MemberRepository memberRepo; + private final OtpCodeRepository otpRepo; + private final MailService mail; + private final JwtTokenProvider jwt; + + /* ------- 메일 발송 (공통) ------- */ + public void sendCode(String email, OtpCode.Type type) { + + /* 1) 회원가입 메일: 이미 가입된 주소면 차단 */ + if (type == SIGNUP && memberRepo.existsByEmail(email)) { + // 추후 커스텀 에러 추가 예정 + throw new IllegalStateException("이미 가입된 이메일입니다."); + } + + /* 2) 로그인 메일: 가입되지 않은 주소(또는 탈퇴 계정)면 차단 */ + if (type == LOGIN) { + Member m = memberRepo.findByEmail(email) + // 추후 커스텀 에러 추가 예정 + .orElseThrow(() -> new IllegalArgumentException("가입되지 않은 이메일입니다.")); + if (Boolean.TRUE.equals(m.getIsDeactivated())) { + // 추후 커스텀 에러 추가 예정 + throw new IllegalStateException("탈퇴한 계정입니다."); + } + } + + /* 3) OTP·토큰 생성 및 저장 */ + String code = mail.generateCode(); + String token = mail.generateToken(); + + otpRepo.save(OtpCode.builder() + .email(email).type(type) + .code(code).token(token) + .expiresAt(LocalDateTime.now().plusMinutes(5)) + .build()); + + /* 4) 메일 발송 */ + mail.sendOtpMail(email, code, token, type); + } + + /* ------- 숫자 코드 검증 ------- */ + public Member signupByCode(String email, String code) { + OtpCode otp = getOtp(email, SIGNUP); + otp.validateUsable(); + if (!otp.getCode().equals(code)) throw new IllegalArgumentException("코드 불일치"); // 추후 커스텀 에러 추가 예정 + return completeSignup(otp); + } + + public TokenResponseDto loginByCode(String email, String code) { + OtpCode otp = getOtp(email, LOGIN); + otp.validateUsable(); + + // 코드 일치 여부 확인 + if (!otp.getCode().equals(code)) { + throw new CustomException(GeneralErrorCode.OTP_WRONG); + } + return completeLogin(otp); + } + + /* ------- 매직링크 검증 ------- */ + public Member signupByToken(String token) { + OtpCode otp = otpRepo.findByToken(token) + .orElseThrow(() -> new IllegalArgumentException("토큰 없음")); // 추후 커스텀 에러 추가 예정 + otp.validateUsable(); + return completeSignup(otp); + } + + public TokenResponseDto loginByToken(String token) { + OtpCode otp = otpRepo.findByToken(token) + .orElseThrow(() -> new IllegalArgumentException("토큰 없음")); // 추후 커스텀 에러 추가 예정 + otp.validateUsable(); + return completeLogin(otp); + } + + /* ------- 공통 후처리 ------- */ + private Member completeSignup(OtpCode otp) { + Member m = memberRepo.save(Member.newMember(otp.getEmail())); + otp.markVerified(); otpRepo.save(otp); + return m; + } + + private TokenResponseDto completeLogin(OtpCode otp) { + Member m = memberRepo.findByEmail(otp.getEmail()) + .orElseThrow(() -> new IllegalArgumentException("회원 없음")); // 추후 커스텀 에러 추가 예정 + m.updateLastLogin(); memberRepo.save(m); + otp.markVerified(); otpRepo.save(otp); + + String access = jwt.generateAccessToken(m.getId(), m.getEmail(), "ROLE_USER"); + return new TokenResponseDto(access); + } + + private OtpCode getOtp(String email, OtpCode.Type type) { + return otpRepo.findById(new OtpCode.PK(email, type)) + .orElseThrow(() -> new IllegalStateException("인증 데이터 없음")); // 추후 커스텀 에러 추가 예정 + } +} diff --git a/src/main/java/UMC/news/newsIntelligent/domain/member/service/MemberQueryService.java b/src/main/java/UMC/news/newsIntelligent/domain/member/service/MemberQueryService.java new file mode 100644 index 0000000..d7ceea5 --- /dev/null +++ b/src/main/java/UMC/news/newsIntelligent/domain/member/service/MemberQueryService.java @@ -0,0 +1,25 @@ +package UMC.news.newsIntelligent.domain.member.service; + +import UMC.news.newsIntelligent.domain.member.converter.MemberInfoConverter; +import UMC.news.newsIntelligent.domain.member.dto.MemberInfoDto; +import UMC.news.newsIntelligent.domain.member.entity.Member; +import UMC.news.newsIntelligent.domain.member.repository.MemberRepository; +import UMC.news.newsIntelligent.global.apiPayload.code.error.GeneralErrorCode; +import UMC.news.newsIntelligent.global.apiPayload.exception.CustomException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class MemberQueryService { + + private final MemberRepository memberRepository; + + public MemberInfoDto getInfo(Long memberId) { + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new CustomException(GeneralErrorCode.MEMBER_NOT_FOUND)); + return MemberInfoConverter.toDto(member); + } +} diff --git a/src/main/java/UMC/news/newsIntelligent/domain/member/service/MemberService.java b/src/main/java/UMC/news/newsIntelligent/domain/member/service/MemberService.java new file mode 100644 index 0000000..5edf9d6 --- /dev/null +++ b/src/main/java/UMC/news/newsIntelligent/domain/member/service/MemberService.java @@ -0,0 +1,48 @@ +package UMC.news.newsIntelligent.domain.member.service; + +import UMC.news.newsIntelligent.domain.member.entity.Member; +import UMC.news.newsIntelligent.domain.member.entity.RevokedToken; +import UMC.news.newsIntelligent.domain.member.repository.MemberRepository; +import UMC.news.newsIntelligent.domain.member.repository.RevokedTokenRepository; +import UMC.news.newsIntelligent.global.apiPayload.code.error.GeneralErrorCode; +import UMC.news.newsIntelligent.global.apiPayload.exception.CustomException; +import UMC.news.newsIntelligent.global.config.security.jwt.JwtTokenProvider; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.time.ZoneId; + +@Service +@Transactional +@RequiredArgsConstructor +public class MemberService { + + private final MemberRepository memberRepository; + private final RevokedTokenRepository revokedTokenRepository; + private final JwtTokenProvider jwtTokenProvider; + + /* --- 로그아웃 --- */ + public void logout(String accessToken) { + String jwtId = jwtTokenProvider.getJwtId(accessToken); + LocalDateTime exp = jwtTokenProvider.getExpiration(accessToken) + .toInstant() + .atZone(ZoneId.systemDefault()) + .toLocalDateTime(); + + revokedTokenRepository.save(new RevokedToken(jwtId, exp)); + } + + /* --- 회원 탈퇴 --- */ + public void withdraw(Member member, String accessToken) { + // 탈퇴 여부 확인 + if (member.isDeactivated()) { + throw new CustomException(GeneralErrorCode.ALREADY_DEACTIVATED); + } + + member.deactivate(); // isDeactivated = true로 설정 + memberRepository.save(member); + logout(accessToken); + } +} diff --git a/src/main/java/UMC/news/newsIntelligent/domain/notification/Notification.java b/src/main/java/UMC/news/newsIntelligent/domain/notification/Notification.java index d9dc655..4f89523 100644 --- a/src/main/java/UMC/news/newsIntelligent/domain/notification/Notification.java +++ b/src/main/java/UMC/news/newsIntelligent/domain/notification/Notification.java @@ -1,6 +1,6 @@ package UMC.news.newsIntelligent.domain.notification; -import UMC.news.newsIntelligent.domain.member.Member; +import UMC.news.newsIntelligent.domain.member.entity.Member; import UMC.news.newsIntelligent.global.entity.BaseEntity; import jakarta.persistence.Column; import jakarta.persistence.Entity; diff --git a/src/main/java/UMC/news/newsIntelligent/global/apiPayload/code/error/GeneralErrorCode.java b/src/main/java/UMC/news/newsIntelligent/global/apiPayload/code/error/GeneralErrorCode.java index 68aa888..56ef6f9 100644 --- a/src/main/java/UMC/news/newsIntelligent/global/apiPayload/code/error/GeneralErrorCode.java +++ b/src/main/java/UMC/news/newsIntelligent/global/apiPayload/code/error/GeneralErrorCode.java @@ -21,8 +21,13 @@ public enum GeneralErrorCode implements BaseErrorCode{ // 유효성 검사 VALIDATION_FAILED(HttpStatus.BAD_REQUEST, "VALID400_0", "잘못된 파라미터 입니다."), // 커서 에러 - CURSOR_INVALID(HttpStatus.BAD_REQUEST, "CURSOR400", "커서가 유효하지 않습니다.") - ; + CURSOR_INVALID(HttpStatus.BAD_REQUEST, "CURSOR400", "커서가 유효하지 않습니다."), + + /* --- 회원/인증 관련 에러 ---*/ + OTP_WRONG ( HttpStatus.BAD_REQUEST, "AUTH401", "인증번호가 일치하지 않습니다."), + OTP_EXPIRED ( HttpStatus.BAD_REQUEST, "AUTH402", "인증번호가 만료되었습니다."), + ALREADY_DEACTIVATED ( HttpStatus.BAD_REQUEST, "MEMBER403", "이미 탈퇴한 계정입니다."), + MEMBER_NOT_FOUND (HttpStatus.BAD_REQUEST, "MEMBER404", "존재하지 않는 회원입니다."); // 필요한 필드값 선언 private final HttpStatus status; diff --git a/src/main/java/UMC/news/newsIntelligent/global/apiPayload/code/success/GeneralSuccessCode.java b/src/main/java/UMC/news/newsIntelligent/global/apiPayload/code/success/GeneralSuccessCode.java index a6aa952..213084d 100644 --- a/src/main/java/UMC/news/newsIntelligent/global/apiPayload/code/success/GeneralSuccessCode.java +++ b/src/main/java/UMC/news/newsIntelligent/global/apiPayload/code/success/GeneralSuccessCode.java @@ -10,6 +10,13 @@ public enum GeneralSuccessCode implements BaseSuccessCode{ OK(HttpStatus.OK, "COMMON200", "성공적으로 처리했습니다."), CREATED(HttpStatus.CREATED, "COMMON201", "성공적으로 생성했습니다."), NO_CONTENT_204(HttpStatus.NO_CONTENT, "COMMON204", "성공했지만 콘텐츠는 없습니다."), + + /* --- 회원/인증 관련 --- */ + EMAIL_SENT (HttpStatus.OK, "MEMBER200", "인증 메일을 발송했습니다."), + SIGNUP_SUCCESS (HttpStatus.OK, "MEMBER201", "회원가입이 완료되었습니다."), + LOGIN_SUCCESS (HttpStatus.OK, "MEMBER202", "로그인에 성공했습니다."), + LOGOUT_SUCCESS (HttpStatus.OK, "MEMBER203", "로그아웃이 완료되었습니다."), + WITHDRAW_SUCCESS (HttpStatus.OK, "MEMBER204", "회원 탈퇴가 완료되었습니다.") ; private final HttpStatus status; diff --git a/src/main/java/UMC/news/newsIntelligent/global/config/EmailConfig.java b/src/main/java/UMC/news/newsIntelligent/global/config/EmailConfig.java new file mode 100644 index 0000000..c26f06a --- /dev/null +++ b/src/main/java/UMC/news/newsIntelligent/global/config/EmailConfig.java @@ -0,0 +1,69 @@ +package UMC.news.newsIntelligent.global.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.JavaMailSenderImpl; + +import java.util.Properties; + +@Configuration +public class EmailConfig { + + @Value("${spring.mail.host}") + private String host; + + @Value("${spring.mail.port}") + private int port; + + @Value("${spring.mail.username}") + private String username; + + @Value("${spring.mail.password}") + private String password; + + @Value("${spring.mail.properties.mail.smtp.auth}") + private boolean auth; + + @Value("${spring.mail.properties.mail.smtp.starttls.enable}") + private boolean starttlsEnable; + + @Value("${spring.mail.properties.mail.smtp.starttls.required}") + private boolean starttlsRequired; + + @Value("${spring.mail.properties.mail.smtp.connectiontimeout}") + private int connectionTimeout; + + @Value("${spring.mail.properties.mail.smtp.timeout}") + private int timeout; + + @Value("${spring.mail.properties.mail.smtp.writetimeout}") + private int writeTimeout; + + @Bean + public JavaMailSender javaMailSender(){ + JavaMailSenderImpl mailSender = new JavaMailSenderImpl(); + mailSender.setHost(host); + mailSender.setPort(port); + mailSender.setUsername(username); + mailSender.setPassword(password); + mailSender.setDefaultEncoding("UTF-8"); + mailSender.setJavaMailProperties(getMailProperties()); + + return mailSender; + } + + private Properties getMailProperties() { + Properties properties = new Properties(); + properties.put("mail.smtp.auth", auth); + properties.put("mail.smtp.starttls.enable", starttlsEnable); + properties.put("mail.smtp.starttls.required", starttlsRequired); + properties.put("mail.smtp.connectiontimeout", connectionTimeout); + properties.put("mail.smtp.timeout", timeout); + properties.put("mail.smtp.writetimeout", writeTimeout); + + return properties; + } + +} diff --git a/src/main/java/UMC/news/newsIntelligent/global/config/properties/JwtProperties.java b/src/main/java/UMC/news/newsIntelligent/global/config/properties/JwtProperties.java index 4393566..5498d34 100644 --- a/src/main/java/UMC/news/newsIntelligent/global/config/properties/JwtProperties.java +++ b/src/main/java/UMC/news/newsIntelligent/global/config/properties/JwtProperties.java @@ -10,7 +10,7 @@ @Setter @ConfigurationProperties("jwt.token") public class JwtProperties { - private String secretKey=""; + private String secretKey; private Expiration expiration; @Getter diff --git a/src/main/java/UMC/news/newsIntelligent/global/config/security/jwt/JwtAuthenticationFilter.java b/src/main/java/UMC/news/newsIntelligent/global/config/security/jwt/JwtAuthenticationFilter.java index c5ed900..b8bbc21 100644 --- a/src/main/java/UMC/news/newsIntelligent/global/config/security/jwt/JwtAuthenticationFilter.java +++ b/src/main/java/UMC/news/newsIntelligent/global/config/security/jwt/JwtAuthenticationFilter.java @@ -16,34 +16,17 @@ @RequiredArgsConstructor public class JwtAuthenticationFilter extends OncePerRequestFilter { - private final JwtTokenProvider jwtTokenProvider; + private final JwtTokenProvider jwt; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, - FilterChain filterChain) - throws ServletException, IOException { + FilterChain chain) throws ServletException, IOException { - try { - String token = resolveToken(request); - if (StringUtils.hasText(token) && jwtTokenProvider.validateToken(token)) { - Authentication authentication = jwtTokenProvider.getAuthentication(token); - SecurityContextHolder.getContext().setAuthentication(authentication); - } - } catch (Exception e) { - System.out.println("[JWT ERROR] " + e.getMessage()); - // 예외를 던지지 않고 그냥 필터 체인을 계속 진행 + String token = JwtTokenProvider.resolveToken(request); + if (token != null && jwt.validate(token)) { + SecurityContextHolder.getContext().setAuthentication(jwt.getAuthentication(token)); } - - filterChain.doFilter(request, response); // 항상 호출 - } - - private String resolveToken(HttpServletRequest request) { - String bearerToken = request.getHeader(Constants.AUTH_HEADER); - - if(StringUtils.hasText(bearerToken) && bearerToken.startsWith(Constants.TOKEN_PREFIX)) { - return bearerToken.substring(Constants.TOKEN_PREFIX.length()); - } - return null; + chain.doFilter(request, response); } } diff --git a/src/main/java/UMC/news/newsIntelligent/global/config/security/jwt/JwtTokenProvider.java b/src/main/java/UMC/news/newsIntelligent/global/config/security/jwt/JwtTokenProvider.java index 17984eb..1753d42 100644 --- a/src/main/java/UMC/news/newsIntelligent/global/config/security/jwt/JwtTokenProvider.java +++ b/src/main/java/UMC/news/newsIntelligent/global/config/security/jwt/JwtTokenProvider.java @@ -18,6 +18,7 @@ import java.security.Key; import java.util.Date; import java.util.Collections; +import java.util.UUID; @Component @RequiredArgsConstructor @@ -25,28 +26,29 @@ public class JwtTokenProvider { private final JwtProperties jwtProperties; - private Key getSigningKey() { + private Key signingKey() { return Keys.hmacShaKeyFor(jwtProperties.getSecretKey().getBytes()); } - public String generateToken(Authentication authentication) { - String email = authentication.getName(); + /* OTP 코드 로그인용 accessToken 발급 (패스워드 방식 x) */ + public String generateAccessToken(Long id, String email, String role) { + long expMs = jwtProperties.getExpiration().getAccess(); + Date now = new Date(); return Jwts.builder() + .setId(UUID.randomUUID().toString()) .setSubject(email) - .claim("role", authentication.getAuthorities().iterator().next().getAuthority()) - .setIssuedAt(new Date()) - .setExpiration(new Date(System.currentTimeMillis() + jwtProperties.getExpiration().getAccess())) - .signWith(getSigningKey(), SignatureAlgorithm.HS256) + .claim("id", id) + .claim("role", role) + .setIssuedAt(now) + .setExpiration(new Date(now.getTime() + expMs)) + .signWith(signingKey(), SignatureAlgorithm.HS256) .compact(); } - public boolean validateToken(String token) { + public boolean validate(String token) { try { - Jwts.parserBuilder() - .setSigningKey(getSigningKey()) - .build() - .parseClaimsJws(token); + Jwts.parserBuilder().setSigningKey(signingKey()).build().parseClaimsJws(token); return true; } catch (JwtException | IllegalArgumentException e) { return false; @@ -55,32 +57,41 @@ public boolean validateToken(String token) { public Authentication getAuthentication(String token) { Claims claims = Jwts.parserBuilder() - .setSigningKey(getSigningKey()) + .setSigningKey(signingKey()) .build() .parseClaimsJws(token) .getBody(); String email = claims.getSubject(); - String role = claims.get("role", String.class); + String role = claims.get("role", String.class); + if (role == null) role = "ROLE_USER"; - User principal = new User(email, "", Collections.singleton(() -> role)); + String finalRole = role; + User principal = new User(email, "", Collections.singleton(() -> finalRole)); return new UsernamePasswordAuthenticationToken(principal, token, principal.getAuthorities()); } public static String resolveToken(HttpServletRequest request) { - String bearerToken = request.getHeader(Constants.AUTH_HEADER); - if(StringUtils.hasText(bearerToken) && bearerToken.startsWith(Constants.TOKEN_PREFIX)) { - return bearerToken.substring(Constants.TOKEN_PREFIX.length()); + String bearer = request.getHeader(Constants.AUTH_HEADER); // e.g. "Authorization" + if (StringUtils.hasText(bearer) && bearer.startsWith(Constants.TOKEN_PREFIX)) { + return bearer.substring(Constants.TOKEN_PREFIX.length()); // "Bearer " } return null; } - public Authentication extractAuthentication(HttpServletRequest request){ - String accessToken = resolveToken(request); - if(accessToken == null || !validateToken(accessToken)) { - //throw new MemberHandler(ErrorStatus.INVALID_TOKEN); - } - return getAuthentication(accessToken); + public String getJwtId(String token) { + return getClaims(token).getId(); // jwtId + } + + public Date getExpiration(String token) { + return getClaims(token).getExpiration(); } + private Claims getClaims(String token) { + return Jwts.parserBuilder() + .setSigningKey(signingKey()) + .build() + .parseClaimsJws(token) + .getBody(); + } }