Skip to content

Commit d17652f

Browse files
authored
Merge branch 'develop' into feat/#15
2 parents 0902c3e + dc2b2fe commit d17652f

33 files changed

+935
-56
lines changed

build.gradle

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,9 @@ dependencies {
5656
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
5757
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
5858
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'
59+
60+
// Mail
61+
implementation 'org.springframework.boot:spring-boot-starter-mail'
5962
}
6063

6164
tasks.named('test') {

docker-compose.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ services:
22
app:
33
build: .
44
ports:
5-
- "8080:8080"
5+
- "8081:8080"
66
env_file:
77
- .env
88
depends_on:
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
package UMC.news.newsIntelligent.domain.mail.dto;
2+
3+
import jakarta.validation.constraints.Email;
4+
5+
public record EmailRequestDto(@Email String email) {
6+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
package UMC.news.newsIntelligent.domain.mail.dto;
2+
3+
import jakarta.validation.constraints.Email;
4+
import jakarta.validation.constraints.Size;
5+
6+
public record VerifyRequestDto(@Email String email,
7+
@Size(min = 6, max = 6) String code) {
8+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
package UMC.news.newsIntelligent.domain.mail.entity;
2+
3+
import UMC.news.newsIntelligent.global.apiPayload.code.error.GeneralErrorCode;
4+
import UMC.news.newsIntelligent.global.apiPayload.exception.CustomException;
5+
import jakarta.persistence.*;
6+
import lombok.*;
7+
8+
import java.io.Serializable;
9+
import java.time.LocalDateTime;
10+
11+
@Entity
12+
@Getter
13+
@NoArgsConstructor(access = AccessLevel.PROTECTED)
14+
@AllArgsConstructor
15+
@Builder
16+
@IdClass(OtpCode.PK.class)
17+
public class OtpCode {
18+
19+
@Id
20+
private String email;
21+
@Id @Enumerated(EnumType.STRING)
22+
private Type type; // SIGNUP, LOGIN
23+
private String code; // 6자리 인증 코드
24+
private String token; // 매직링크용 토큰
25+
private LocalDateTime expiresAt;
26+
27+
/** 완료 플래그:
28+
* otp code나 매직링크 둘 중 하나로 검증되면 true
29+
* → 재사용 방지 */
30+
@Column(nullable = false) @Builder.Default
31+
private Boolean verified = false;
32+
33+
public enum Type { SIGNUP, LOGIN; }
34+
35+
@Getter @Setter
36+
@NoArgsConstructor @AllArgsConstructor
37+
public static class PK implements Serializable {
38+
private String email;
39+
private Type type;
40+
}
41+
42+
public void validateUsable() {
43+
if (Boolean.TRUE.equals(verified))
44+
throw new CustomException(GeneralErrorCode.OTP_WRONG); // 불일치
45+
if (expiresAt.isBefore(LocalDateTime.now()))
46+
throw new CustomException(GeneralErrorCode.OTP_EXPIRED); // 만료
47+
}
48+
49+
public void markVerified() { this.verified = true; }
50+
51+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package UMC.news.newsIntelligent.domain.mail.repository;
2+
3+
import UMC.news.newsIntelligent.domain.mail.entity.OtpCode;
4+
import org.springframework.data.jpa.repository.JpaRepository;
5+
6+
import java.util.Optional;
7+
8+
public interface OtpCodeRepository extends JpaRepository<OtpCode, OtpCode.PK> {
9+
Optional<OtpCode> findByToken(String token);
10+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
package UMC.news.newsIntelligent.domain.mail.service;
2+
3+
import UMC.news.newsIntelligent.domain.mail.entity.OtpCode;
4+
import jakarta.mail.Message;
5+
import jakarta.mail.internet.InternetAddress;
6+
import jakarta.mail.internet.MimeMessage;
7+
import lombok.RequiredArgsConstructor;
8+
import lombok.extern.slf4j.Slf4j;
9+
import org.springframework.beans.factory.annotation.Value;
10+
import org.springframework.mail.javamail.JavaMailSender;
11+
import org.springframework.stereotype.Service;
12+
import java.security.SecureRandom;
13+
import java.util.UUID;
14+
15+
@Slf4j
16+
@Service
17+
@RequiredArgsConstructor
18+
public class MailService {
19+
20+
private final JavaMailSender mailSender;
21+
@Value("${spring.mail.username}") private String from;
22+
23+
public String generateCode() {
24+
return String.format("%06d", new SecureRandom().nextInt(1_000_000));
25+
}
26+
public String generateToken() {
27+
return UUID.randomUUID().toString().replace("-", ""); // 32 hex
28+
}
29+
30+
public void sendOtpMail(String to, String code, String token, OtpCode.Type type) {
31+
String path = (type == OtpCode.Type.SIGNUP) ? "signup" : "login";
32+
String link = "https://newsintelligent.io/%s/magic?token=%s".formatted(path, token);
33+
34+
String html = """
35+
<div style='margin:80px;font-family:Verdana'>
36+
<h2>NewsIntelligent 인증 메일입니다.</h2>
37+
<p>아래 <b>코드</b>를 입력하거나 <b>매직링크</b>를 클릭하세요.</p>
38+
<h1 style='color:#3366ff'>%s</h1>
39+
<p><a href='%s'>➡️ 매직링크 바로가기</a> · 5분 내 1회 사용</p>
40+
</div>
41+
""".formatted(code, link);
42+
43+
try {
44+
MimeMessage msg = mailSender.createMimeMessage();
45+
msg.addRecipients(Message.RecipientType.TO, to);
46+
msg.setSubject("[NewsIntelligent] 인증 메일");
47+
msg.setText(html, "utf-8", "html");
48+
msg.setFrom(new InternetAddress(from, "NewsIntelligent"));
49+
mailSender.send(msg);
50+
} catch (Exception e) {
51+
throw new IllegalStateException("메일 발송 실패", e);
52+
}
53+
}
54+
}
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
package UMC.news.newsIntelligent.domain.member.controller;
2+
3+
import UMC.news.newsIntelligent.domain.mail.dto.EmailRequestDto;
4+
import UMC.news.newsIntelligent.domain.mail.dto.VerifyRequestDto;
5+
import UMC.news.newsIntelligent.domain.mail.entity.OtpCode;
6+
import UMC.news.newsIntelligent.domain.member.dto.MemberResponseDto;
7+
import UMC.news.newsIntelligent.domain.member.dto.TokenResponseDto;
8+
import UMC.news.newsIntelligent.domain.member.entity.Member;
9+
import UMC.news.newsIntelligent.domain.member.repository.MemberRepository;
10+
import UMC.news.newsIntelligent.domain.member.service.AuthService;
11+
import UMC.news.newsIntelligent.domain.member.service.MemberService;
12+
import UMC.news.newsIntelligent.global.apiPayload.CustomResponse;
13+
import UMC.news.newsIntelligent.global.apiPayload.code.success.GeneralSuccessCode;
14+
import UMC.news.newsIntelligent.global.config.security.jwt.JwtTokenProvider;
15+
import io.swagger.v3.oas.annotations.Operation;
16+
import io.swagger.v3.oas.annotations.tags.Tag;
17+
import jakarta.servlet.http.HttpServletRequest;
18+
import lombok.RequiredArgsConstructor;
19+
import lombok.extern.slf4j.Slf4j;
20+
import org.springframework.security.core.annotation.AuthenticationPrincipal;
21+
import org.springframework.security.core.userdetails.UserDetails;
22+
import org.springframework.web.bind.annotation.*;
23+
import org.springframework.web.servlet.view.RedirectView;
24+
25+
@Slf4j
26+
@RestController
27+
@RequiredArgsConstructor
28+
@RequestMapping("/api/members")
29+
@Tag(name="사용자 및 인증 관련 API", description = "사용자 인증 및 가입/로그인/로그아웃/탈퇴")
30+
public class AuthController {
31+
32+
private final AuthService auth;
33+
private final MemberService memberService;
34+
private final MemberRepository memberRepository;
35+
36+
/* --- 메일 발송 --- */
37+
@Operation(summary = "회원가입 인증번호 전송", description = "회원가입 시 사용자에게 이메일로 인증번호를 전송하는 API입니다.")
38+
@PostMapping("/signup/email")
39+
public CustomResponse<?> signupEmail(@RequestBody EmailRequestDto request) {
40+
auth.sendCode(request.email(), OtpCode.Type.SIGNUP);
41+
return CustomResponse.onSuccess(GeneralSuccessCode.EMAIL_SENT);
42+
}
43+
44+
@Operation(summary = "로그인 인증번호 전송", description = "로그인 시 사용자에게 이메일로 인증번호를 전송하는 API입니다.")
45+
@PostMapping("/login/email")
46+
public CustomResponse<?> loginEmail(@RequestBody EmailRequestDto request) {
47+
auth.sendCode(request.email(), OtpCode.Type.LOGIN);
48+
return CustomResponse.onSuccess(GeneralSuccessCode.EMAIL_SENT);
49+
}
50+
51+
/* --- 인증 코드 검증 --- */
52+
@Operation(summary = "회원가입 인증코드 검증", description = "회원가입 시 전송된 6자리 코드를 검증하는 API입니다.")
53+
@PostMapping("/signup/verify")
54+
public CustomResponse<MemberResponseDto> signupVerify(@RequestBody VerifyRequestDto request) {
55+
MemberResponseDto responseDto = MemberResponseDto.from(auth.signupByCode(request.email(), request.code()));
56+
return CustomResponse.onSuccess(GeneralSuccessCode.SIGNUP_SUCCESS, responseDto);
57+
}
58+
@Operation(summary = "로그인 인증코드 검증", description = "로그인 시 전송된 6자리 코드를 검증하는 API입니다.")
59+
@PostMapping("/login/verify")
60+
public CustomResponse<TokenResponseDto> loginVerify(@RequestBody VerifyRequestDto request) {
61+
TokenResponseDto responseDto = auth.loginByCode(request.email(), request.code());
62+
return CustomResponse.onSuccess(GeneralSuccessCode.LOGIN_SUCCESS, responseDto);
63+
}
64+
65+
/* --- 로그아웃 --- */
66+
@Operation(summary = "로그아웃", description = "액세스 토큰을 무효화하는 API입니다.")
67+
@PostMapping("/logout")
68+
public CustomResponse<?> logout(HttpServletRequest request,
69+
@AuthenticationPrincipal UserDetails userDetails) {
70+
String token = JwtTokenProvider.resolveToken(request);
71+
memberService.logout(token);
72+
return CustomResponse.onSuccess(GeneralSuccessCode.LOGOUT_SUCCESS);
73+
}
74+
75+
/* --- 회원 탈퇴 --- */
76+
@Operation(summary = "회원 탈퇴", description = "회원 탈퇴 후 액세스 토큰을 무효화하는 API입니다.")
77+
@DeleteMapping("/withdraw")
78+
public CustomResponse<?> withdraw(HttpServletRequest request,
79+
@AuthenticationPrincipal UserDetails userDetails) {
80+
String token = JwtTokenProvider.resolveToken(request);
81+
Member member = memberRepository.findByEmail(userDetails.getUsername())
82+
.orElseThrow();
83+
memberService.withdraw(member, token);
84+
return CustomResponse.onSuccess(GeneralSuccessCode.WITHDRAW_SUCCESS);
85+
}
86+
87+
88+
/* --- 매직 링크 --- */
89+
@Operation(summary = "리다이렉트용", description = "프론트엔드에서 구현 필요 X")
90+
@GetMapping("/signup/magic")
91+
public RedirectView signupMagic(@RequestParam String token) {
92+
auth.signupByToken(token);
93+
return new RedirectView("/signup/success");
94+
}
95+
@Operation(summary = "리다이렉트용", description = "프론트엔드에서 구현 필요 X")
96+
@GetMapping("/login/magic")
97+
public RedirectView loginMagic(@RequestParam String token) {
98+
TokenResponseDto tr = auth.loginByToken(token);
99+
return new RedirectView("/login/magic-success#" + tr.accessToken());
100+
}
101+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package UMC.news.newsIntelligent.domain.member.controller;
2+
3+
import UMC.news.newsIntelligent.domain.member.dto.MemberInfoDto;
4+
import UMC.news.newsIntelligent.domain.member.service.MemberQueryService;
5+
import UMC.news.newsIntelligent.global.apiPayload.CustomResponse;
6+
import UMC.news.newsIntelligent.global.apiPayload.code.success.GeneralSuccessCode;
7+
import io.swagger.v3.oas.annotations.Operation;
8+
import io.swagger.v3.oas.annotations.tags.Tag;
9+
import lombok.RequiredArgsConstructor;
10+
import org.springframework.web.bind.annotation.GetMapping;
11+
import org.springframework.web.bind.annotation.PathVariable;
12+
import org.springframework.web.bind.annotation.RequestMapping;
13+
import org.springframework.web.bind.annotation.RestController;
14+
15+
import java.util.List;
16+
17+
@RestController
18+
@RequiredArgsConstructor
19+
@RequestMapping("/api/members")
20+
@Tag(name="회원 관련 API", description = "회원 정보 조회/수정")
21+
public class MemberController {
22+
private final MemberQueryService memberQueryService;
23+
24+
/* --- 회원 정보 조회 --- */
25+
@Operation(summary = "회원 정보 조회", description = "회원정보를 리스트로 반환하는 API입니다.")
26+
@GetMapping("/info/{memberId}")
27+
public CustomResponse<List<MemberInfoDto>> getInfo(@PathVariable Long memberId) {
28+
29+
MemberInfoDto dto = memberQueryService.getInfo(memberId);
30+
31+
return CustomResponse.onSuccess(
32+
GeneralSuccessCode.OK,
33+
List.of(dto)
34+
);
35+
}
36+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package UMC.news.newsIntelligent.domain.member.converter;
2+
3+
import UMC.news.newsIntelligent.domain.member.dto.MemberInfoDto;
4+
import UMC.news.newsIntelligent.domain.member.entity.Member;
5+
6+
import java.util.List;
7+
import java.util.stream.Collectors;
8+
9+
public class MemberInfoConverter {
10+
private MemberInfoConverter() {} // 인스턴스화 방지
11+
12+
public static MemberInfoDto toDto(Member member) {
13+
return new MemberInfoDto(
14+
member.getEmail(),
15+
member.getSubscribeTopicAlert(),
16+
member.getReadTopicAlert(),
17+
member.getDailyReportAlert(),
18+
member.getCreatedAt(),
19+
member.getUpdatedAt(),
20+
member.getIsDeactivated()
21+
);
22+
}
23+
24+
/* entity -> dto */
25+
public static List<MemberInfoDto> toDtoList(List<Member> members) {
26+
return members.stream()
27+
.map(MemberInfoConverter::toDto)
28+
.collect(Collectors.toList());
29+
}
30+
}

0 commit comments

Comments
 (0)