Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
fce9571
Chore: mail 의존성 추가
caminobelllo Jul 31, 2025
d07c184
Feat: email config 작성
caminobelllo Jul 31, 2025
9dfb603
Refactor: jwt 이메일 인증 방식으로 변경
caminobelllo Jul 31, 2025
e24fdf5
Feat: otp entity 구현
caminobelllo Jul 31, 2025
e6c715f
Feat: 회원가입 및 로그인 로직 구현
caminobelllo Jul 31, 2025
13a8ff5
Feat: 회원 관련 dto 구현
caminobelllo Jul 31, 2025
2d964b4
Feat: 메일 전송 서비스 구현
caminobelllo Jul 31, 2025
11d7b0e
Feat: 회원가입 및 로그인 repository 구현
caminobelllo Jul 31, 2025
d36cb23
Chore: 로컬 포트 8080->8081로 변경
caminobelllo Jul 31, 2025
4fb77fc
Feat: 회원가입 및 로그인 controller 구현
caminobelllo Jul 31, 2025
18004ea
Feat: 회원 및 인증 성공 코드 추가
caminobelllo Jul 31, 2025
3aeaa36
Refactor: 응답 통일
caminobelllo Jul 31, 2025
c07c28c
Refactor: 에러 응답 추가
caminobelllo Jul 31, 2025
1d9bd52
Chore: swagger 설정 추가
caminobelllo Jul 31, 2025
9cb08d9
Refactor: 폴더 구조 변경
caminobelllo Jul 31, 2025
baeee6e
Refactor: 폴더 구조 변경
caminobelllo Jul 31, 2025
507c268
Feat: 토큰 무효화 관련 구현
caminobelllo Jul 31, 2025
2489646
Refactor: 폴더 구조 변경
caminobelllo Jul 31, 2025
b289033
Feat: 로그아웃/탈퇴 로직 구현
caminobelllo Jul 31, 2025
44473fe
Feat: 로그아웃용 jwt id 관련
caminobelllo Jul 31, 2025
c96ee9b
Feat: 성공/에러 응답 코드 추가
caminobelllo Jul 31, 2025
0718a15
Refactor: 폴더 구조 변경
caminobelllo Jul 31, 2025
9ea2c0c
Feat: 로그아웃/탈퇴 controller 구현
caminobelllo Jul 31, 2025
09aced6
Feat: 오류 응답 코드 추가
caminobelllo Jul 31, 2025
a1ae937
Feat: 회원 정보 dto 구현
caminobelllo Jul 31, 2025
d853eb9
Feat: 회원 정보 조회 로직 구현
caminobelllo Jul 31, 2025
80a5f1a
Feat: 회원 정보 조회 controller 구현
caminobelllo Jul 31, 2025
71d6340
Refactor: dto/converter 분리
caminobelllo Jul 31, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand Down
2 changes: 1 addition & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ services:
app:
build: .
ports:
- "8080:8080"
- "8081:8080"
env_file:
- .env
depends_on:
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package UMC.news.newsIntelligent.domain.mail.dto;

import jakarta.validation.constraints.Email;

public record EmailRequestDto(@Email String email) {
}
Original file line number Diff line number Diff line change
@@ -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) {
}
Original file line number Diff line number Diff line change
@@ -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; }

}
Original file line number Diff line number Diff line change
@@ -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<OtpCode, OtpCode.PK> {
Optional<OtpCode> findByToken(String token);
}
Original file line number Diff line number Diff line change
@@ -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 = """
<div style='margin:80px;font-family:Verdana'>
<h2>NewsIntelligent 인증 메일입니다.</h2>
<p>아래 <b>코드</b>를 입력하거나 <b>매직링크</b>를 클릭하세요.</p>
<h1 style='color:#3366ff'>%s</h1>
<p><a href='%s'>➡️ 매직링크 바로가기</a> · 5분 내 1회 사용</p>
</div>
""".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);
}
}
}
Original file line number Diff line number Diff line change
@@ -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<MemberResponseDto> 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<TokenResponseDto> 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());
}
}
Original file line number Diff line number Diff line change
@@ -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<List<MemberInfoDto>> getInfo(@PathVariable Long memberId) {

MemberInfoDto dto = memberQueryService.getInfo(memberId);

return CustomResponse.onSuccess(
GeneralSuccessCode.OK,
List.of(dto)
);
}
}
Original file line number Diff line number Diff line change
@@ -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<MemberInfoDto> toDtoList(List<Member> members) {
return members.stream()
.map(MemberInfoConverter::toDto)
.collect(Collectors.toList());
}
}
Original file line number Diff line number Diff line change
@@ -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
) {}
Original file line number Diff line number Diff line change
@@ -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());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package UMC.news.newsIntelligent.domain.member.dto;

public record TokenResponseDto(String accessToken) {
}
Loading