diff --git a/build.gradle b/build.gradle index 0938979..382346a 100644 --- a/build.gradle +++ b/build.gradle @@ -28,6 +28,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.boot:spring-boot-starter-mail' compileOnly 'org.projectlombok:lombok' developmentOnly 'org.springframework.boot:spring-boot-devtools' runtimeOnly 'org.postgresql:postgresql' diff --git a/src/main/java/com/kusitms/website/WebsiteApplication.java b/src/main/java/com/kusitms/website/WebsiteApplication.java index 18ca950..649470c 100644 --- a/src/main/java/com/kusitms/website/WebsiteApplication.java +++ b/src/main/java/com/kusitms/website/WebsiteApplication.java @@ -2,7 +2,9 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableAsync; +@EnableAsync @SpringBootApplication public class WebsiteApplication { diff --git a/src/main/java/com/kusitms/website/domain/admin/AdminController.java b/src/main/java/com/kusitms/website/domain/admin/AdminController.java index 866fca0..c21b159 100644 --- a/src/main/java/com/kusitms/website/domain/admin/AdminController.java +++ b/src/main/java/com/kusitms/website/domain/admin/AdminController.java @@ -1,5 +1,6 @@ package com.kusitms.website.domain.admin; +import com.kusitms.website.domain.admin.dto.response.PendingMemberResponse; import com.kusitms.website.domain.introduction.IntroService; import com.kusitms.website.domain.introduction.dto.request.IntroRequest; import com.kusitms.website.domain.blog.dto.request.BlogReviewRequest; @@ -18,9 +19,12 @@ import lombok.RequiredArgsConstructor; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; import javax.servlet.http.HttpServletRequest; +import java.util.List; +import java.util.Map; @RestController @RequiredArgsConstructor @@ -28,6 +32,7 @@ public class AdminController { private final IntroService introService; private final AdminService adminService; + private final MemberAdminService memberAdminService; private final JwtTokenProvider jwtTokenProvider; @GetMapping("/admin/introductions") @@ -251,4 +256,31 @@ public ResponseEntity deleteBlogReview(@PathVariable("id") Long bl adminService.deleteBlogReview(blogReviewId); return ResponseEntity.ok(new BaseResponse()); } + + @GetMapping("/admin/members/pending") + @PreAuthorize("hasRole('ADMIN')") + @Operation(summary = "승인 대기 회원 목록 조회", description = "가입 승인 대기 중인 회원 목록을 조회합니다.") + public ResponseEntity>> getPendingMembers() { + return ResponseEntity.ok(new BaseResponse<>(memberAdminService.getPendingMembers())); + } + + @PostMapping("/admin/members/{userId}/approve") + @PreAuthorize("hasRole('ADMIN')") + @Operation(summary = "회원 가입 승인", description = "대기 중인 회원의 가입을 승인합니다.") + public ResponseEntity approveMember( + @PathVariable Long userId + ) { + memberAdminService.approveMember(userId); + return ResponseEntity.ok(new BaseResponse()); + } + + @PostMapping("/admin/members/{userId}/reject") + @PreAuthorize("hasRole('ADMIN')") + @Operation(summary = "회원 가입 반려", description = "대기 중인 회원의 가입을 반려하고 계정을 삭제합니다.") + public ResponseEntity rejectMember( + @PathVariable Long userId + ) { + memberAdminService.rejectMember(userId); + return ResponseEntity.ok(new BaseResponse()); + } } diff --git a/src/main/java/com/kusitms/website/domain/admin/MemberAdminService.java b/src/main/java/com/kusitms/website/domain/admin/MemberAdminService.java new file mode 100644 index 0000000..3ed1658 --- /dev/null +++ b/src/main/java/com/kusitms/website/domain/admin/MemberAdminService.java @@ -0,0 +1,61 @@ +package com.kusitms.website.domain.admin; + +import com.kusitms.website.domain.admin.dto.response.PendingMemberResponse; +import com.kusitms.website.domain.email.service.MailService; +import com.kusitms.website.domain.user.Member; +import com.kusitms.website.domain.user.MemberRepository; +import com.kusitms.website.domain.user.MemberStatus; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class MemberAdminService { + + private final MemberRepository memberRepository; + private final MailService mailService; + + public List getPendingMembers() { + return memberRepository.findAllByStatus(MemberStatus.PENDING).stream() + .map(PendingMemberResponse::from) + .collect(Collectors.toList()); + } + + @Transactional + public void approveMember(Long userId) { + Member member = memberRepository.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 회원입니다.")); + + if (member.getStatus() != MemberStatus.PENDING) { + throw new IllegalArgumentException("승인 대기 상태가 아닌 회원입니다."); + } + + member.approve(); + + if (member.getEmail() != null && !member.getEmail().isBlank()) { + mailService.sendApprovalEmail(member.getEmail(), member.getName()); + } + } + + @Transactional + public void rejectMember(Long userId) { + Member member = memberRepository.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 회원입니다.")); + + if (member.getStatus() != MemberStatus.PENDING) { + throw new IllegalArgumentException("승인 대기 상태가 아닌 회원입니다."); + } + + String name = member.getName(); + String email = member.getEmail(); + memberRepository.delete(member); + + if (email != null && !email.isBlank()) { + mailService.sendRejectionEmail(email, name); + } + } +} diff --git a/src/main/java/com/kusitms/website/domain/admin/dto/response/PendingMemberResponse.java b/src/main/java/com/kusitms/website/domain/admin/dto/response/PendingMemberResponse.java new file mode 100644 index 0000000..c6c56a8 --- /dev/null +++ b/src/main/java/com/kusitms/website/domain/admin/dto/response/PendingMemberResponse.java @@ -0,0 +1,48 @@ +package com.kusitms.website.domain.admin.dto.response; + +import com.kusitms.website.domain.user.Member; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +@Schema(description = "승인 대기 회원 응답") +public class PendingMemberResponse { + @Schema(description = "회원 PK") + private Long userId; + + @Schema(description = "아이디") + private String id; + + @Schema(description = "이름") + private String name; + + @Schema(description = "휴대폰 번호") + private String phone; + + @Schema(description = "활동 기수") + private Integer cardinal; + + @Schema(description = "파트") + private String part; + + @Schema(description = "프로필 이미지 URL") + private String profileImageUrl; + + @Schema(description = "수료증 이미지 URL") + private String certificateImageUrl; + + public static PendingMemberResponse from(Member member) { + return new PendingMemberResponse( + member.getUserId(), + member.getId(), + member.getName(), + member.getPhone(), + member.getCardinal(), + member.getPart().name(), + member.getProfileImageUrl(), + member.getCertificateImageUrl() + ); + } +} diff --git a/src/main/java/com/kusitms/website/domain/email/service/MailService.java b/src/main/java/com/kusitms/website/domain/email/service/MailService.java new file mode 100644 index 0000000..67e53a7 --- /dev/null +++ b/src/main/java/com/kusitms/website/domain/email/service/MailService.java @@ -0,0 +1,99 @@ +package com.kusitms.website.domain.email.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.MimeMessageHelper; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; + +import javax.mail.internet.MimeMessage; + +@Slf4j +@Service +@RequiredArgsConstructor +public class MailService { + + private static final String BANNER_URL = "https://kusitms-bucket.s3.ap-northeast-2.amazonaws.com/meetup/OG/456ed518-7489-4106-9d33-94c491ef358732__OG_.png"; + private static final String BRAND_COLOR = "#4A3AFF"; + + private final JavaMailSender mailSender; + + @Async + public void sendApprovalEmail(String toEmail, String name) { + String subject = "[큐시즘] 회원가입이 승인되었습니다"; + String html = buildEmail(name, + "회원가입 승인 완료", + "큐시즘 회원가입이 승인되었습니다.
지금 바로 로그인하여 서비스를 이용하실 수 있습니다.", + "로그인하기", + "https://kusitms.com"); + sendHtmlEmail(toEmail, subject, html); + } + + @Async + public void sendRejectionEmail(String toEmail, String name) { + String subject = "[큐시즘] 회원가입 신청이 반려되었습니다"; + String html = buildEmail(name, + "회원가입 반려 안내", + "큐시즘 회원가입 신청이 반려되었습니다.
문의 사항이 있으시면 운영진에게 연락해 주세요.", + null, null); + sendHtmlEmail(toEmail, subject, html); + } + + private void sendHtmlEmail(String toEmail, String subject, String html) { + try { + MimeMessage mime = mailSender.createMimeMessage(); + MimeMessageHelper helper = new MimeMessageHelper(mime, "UTF-8"); + helper.setTo(toEmail); + helper.setSubject(subject); + helper.setText(html, true); + mailSender.send(mime); + } catch (Exception e) { + log.error("이메일 발송 실패: {}", toEmail, e); + } + } + + private String buildEmail(String name, String title, String body, String buttonText, String buttonUrl) { + StringBuilder sb = new StringBuilder(); + sb.append(""); + sb.append(""); + sb.append("
"); + sb.append(""); + + // Banner + sb.append(""); + + // Title + sb.append(""); + + // Body + sb.append(""); + + // Button + if (buttonText != null && buttonUrl != null) { + sb.append(""); + } + + // Footer + sb.append(""); + + sb.append("
"); + sb.append("KUSITMS"); + sb.append("
"); + sb.append("

").append(title).append("

"); + sb.append("
"); + sb.append("

"); + sb.append(name).append("님, 안녕하세요.

"); + sb.append(body); + sb.append("

"); + sb.append("").append(buttonText).append(""); + sb.append("
"); + sb.append("
"); + sb.append("

"); + sb.append("본 메일은 큐시즘에서 발송한 자동 알림 메일입니다.
"); + sb.append("© KUSITMS. All rights reserved."); + sb.append("

"); + return sb.toString(); + } +} diff --git a/src/main/java/com/kusitms/website/domain/user/CurrentCardinal.java b/src/main/java/com/kusitms/website/domain/user/CurrentCardinal.java new file mode 100644 index 0000000..ddd9c5e --- /dev/null +++ b/src/main/java/com/kusitms/website/domain/user/CurrentCardinal.java @@ -0,0 +1,25 @@ +package com.kusitms.website.domain.user; + +import lombok.Getter; +import lombok.NoArgsConstructor; + +import javax.persistence.*; + +@Entity +@Getter +@NoArgsConstructor +public class CurrentCardinal { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private Integer cardinal; + + public CurrentCardinal(Integer cardinal) { + this.cardinal = cardinal; + } + + public void updateCardinal(Integer cardinal) { + this.cardinal = cardinal; + } +} diff --git a/src/main/java/com/kusitms/website/domain/user/CurrentCardinalRepository.java b/src/main/java/com/kusitms/website/domain/user/CurrentCardinalRepository.java new file mode 100644 index 0000000..b9e10a0 --- /dev/null +++ b/src/main/java/com/kusitms/website/domain/user/CurrentCardinalRepository.java @@ -0,0 +1,6 @@ +package com.kusitms.website.domain.user; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface CurrentCardinalRepository extends JpaRepository { +} diff --git a/src/main/java/com/kusitms/website/domain/user/Member.java b/src/main/java/com/kusitms/website/domain/user/Member.java index 2684313..e2b4653 100644 --- a/src/main/java/com/kusitms/website/domain/user/Member.java +++ b/src/main/java/com/kusitms/website/domain/user/Member.java @@ -2,28 +2,82 @@ import lombok.Builder; import lombok.Getter; +import lombok.NoArgsConstructor; import javax.persistence.*; +import java.time.LocalDateTime; @Entity @Getter +@NoArgsConstructor public class Member { @Id @Column(name = "user_id", nullable = false) @GeneratedValue(strategy = GenerationType.IDENTITY) private Long userId; + @Column(unique = true) private String id; private String password; + private String name; + + private String phone; + + private Integer cardinal; + + @Enumerated(EnumType.STRING) + private Part part; + + private String profileImageUrl; + + private String certificateImageUrl; + + private String email; + + @Enumerated(EnumType.STRING) + private MemberStatus status; + + @Enumerated(EnumType.STRING) + @Column(name = "member_role") + private MemberRole role; + + private String refreshToken; + + private LocalDateTime createdAt; + @Builder - public Member(String id, String password) { + public Member(String id, String password, String name, String phone, + Integer cardinal, Part part, String profileImageUrl, + String certificateImageUrl, String email, MemberStatus status, MemberRole role) { this.id = id; this.password = password; + this.name = name; + this.phone = phone; + this.cardinal = cardinal; + this.part = part; + this.profileImageUrl = profileImageUrl; + this.certificateImageUrl = certificateImageUrl; + this.email = email; + this.status = status; + this.role = role; + this.createdAt = LocalDateTime.now(); + } + + public void updateRefreshToken(String refreshToken) { + this.refreshToken = refreshToken; + } + + public void clearRefreshToken() { + this.refreshToken = null; } - public Member() { + public void updateRole(MemberRole role) { + this.role = role; + } + public void approve() { + this.status = MemberStatus.APPROVED; } } diff --git a/src/main/java/com/kusitms/website/domain/user/MemberController.java b/src/main/java/com/kusitms/website/domain/user/MemberController.java index 3fcd4ed..fe2bfb7 100644 --- a/src/main/java/com/kusitms/website/domain/user/MemberController.java +++ b/src/main/java/com/kusitms/website/domain/user/MemberController.java @@ -2,32 +2,66 @@ import com.kusitms.website.domain.user.dto.request.SignInRequest; import com.kusitms.website.domain.user.dto.request.SignUpRequest; +import com.kusitms.website.domain.user.dto.response.CurrentCardinalResponse; +import com.kusitms.website.domain.user.dto.response.SignInResponse; import com.kusitms.website.global.common.BaseResponse; -import io.swagger.v3.oas.annotations.Hidden; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; + +import javax.validation.Valid; +import org.springframework.web.multipart.MultipartFile; + +import java.util.Map; @RestController @RequiredArgsConstructor -@RequestMapping("/api") +@RequestMapping("/api/auth") +@Tag(name = "Auth", description = "인증 API") public class MemberController { private final MemberService memberService; - @Hidden - @PostMapping("/signup") - public ResponseEntity signup (@RequestBody SignUpRequest request) { - Long userId = memberService.save(request); + @Operation(summary = "회원가입") + @PostMapping(value = "/signup", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity signup( + @RequestPart("signUpRequest") @Valid SignUpRequest request, + @RequestPart("profileImage") MultipartFile profileImage, + @RequestPart(value = "certificateImage", required = false) MultipartFile certificateImage + ) { + Long userId = memberService.signup(request, profileImage, certificateImage); return ResponseEntity.ok(new BaseResponse(userId)); } + @Operation(summary = "로그인") @PostMapping("/signin") - public ResponseEntity signin (@RequestBody SignInRequest request) { - String token = memberService.signin(request); - return ResponseEntity.ok(new BaseResponse(token)); + public ResponseEntity> signin(@RequestBody SignInRequest request) { + SignInResponse response = memberService.signin(request); + return ResponseEntity.ok(new BaseResponse<>(response)); + } + + @Operation(summary = "아이디 중복 체크") + @GetMapping("/check-id") + public ResponseEntity>> checkId(@RequestParam String id) { + Map result = memberService.checkIdDuplicate(id); + return ResponseEntity.ok(new BaseResponse<>(result)); + } + + @Operation(summary = "토큰 갱신") + @PostMapping("/refresh") + public ResponseEntity> refresh(@RequestBody Map request) { + String refreshToken = request.get("refreshToken"); + SignInResponse response = memberService.refreshToken(refreshToken); + return ResponseEntity.ok(new BaseResponse<>(response)); + } + + @Operation(summary = "현재 기수 조회") + @GetMapping("/current-cardinal") + public ResponseEntity> getCurrentCardinal() { + CurrentCardinalResponse response = memberService.getCurrentCardinal(); + return ResponseEntity.ok(new BaseResponse<>(response)); } } diff --git a/src/main/java/com/kusitms/website/domain/user/MemberRepository.java b/src/main/java/com/kusitms/website/domain/user/MemberRepository.java index e977334..e9d4acd 100644 --- a/src/main/java/com/kusitms/website/domain/user/MemberRepository.java +++ b/src/main/java/com/kusitms/website/domain/user/MemberRepository.java @@ -2,8 +2,17 @@ import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; import java.util.Optional; public interface MemberRepository extends JpaRepository { + Optional findByIdAndStatus(String id, MemberStatus status); + Optional findById(String id); + + boolean existsById(String id); + + List findAllByStatus(MemberStatus status); + + Optional findByRefreshToken(String refreshToken); } diff --git a/src/main/java/com/kusitms/website/domain/user/MemberRole.java b/src/main/java/com/kusitms/website/domain/user/MemberRole.java new file mode 100644 index 0000000..88430ed --- /dev/null +++ b/src/main/java/com/kusitms/website/domain/user/MemberRole.java @@ -0,0 +1,7 @@ +package com.kusitms.website.domain.user; + +public enum MemberRole { + YB, + OB, + ADMIN +} diff --git a/src/main/java/com/kusitms/website/domain/user/MemberService.java b/src/main/java/com/kusitms/website/domain/user/MemberService.java index 97a7079..7c5c61a 100644 --- a/src/main/java/com/kusitms/website/domain/user/MemberService.java +++ b/src/main/java/com/kusitms/website/domain/user/MemberService.java @@ -1,50 +1,230 @@ package com.kusitms.website.domain.user; +import com.kusitms.website.domain.file.S3Service; import com.kusitms.website.domain.user.dto.request.SignInRequest; import com.kusitms.website.domain.user.dto.request.SignUpRequest; +import com.kusitms.website.domain.user.dto.response.CurrentCardinalResponse; +import com.kusitms.website.domain.user.dto.response.SignInResponse; import com.kusitms.website.global.auth.jwt.JwtTokenProvider; import lombok.RequiredArgsConstructor; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.List; +import java.util.Map; +import java.util.regex.Pattern; @Service @RequiredArgsConstructor public class MemberService { + private static final Pattern PASSWORD_PATTERN = + Pattern.compile("^(?=.*[a-zA-Z])(?=.*\\d)(?=.*[!@#$%^&*()_+\\-=\\[\\]{};':\"\\\\|,.<>/?]).{8,}$"); + private static final Pattern PHONE_PATTERN = + Pattern.compile("^010\\d{8}$"); + private static final long MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB + private static final List ALLOWED_EXTENSIONS = List.of("jpg", "jpeg", "png"); + private final BCryptPasswordEncoder bCryptPasswordEncoder; private final JwtTokenProvider jwtTokenProvider; - private final MemberRepository memberRepository; + private final CurrentCardinalRepository currentCardinalRepository; + private final S3Service s3Service; + + @Transactional + public Long signup(SignUpRequest request, MultipartFile profileImage, MultipartFile certificateImage) { + validateSignUpRequest(request, certificateImage); + + if (memberRepository.existsById(request.getId())) { + throw new IllegalArgumentException("이미 사용 중인 아이디입니다."); + } + + validateImageFile(profileImage, "프로필 사진"); + if (certificateImage != null && !certificateImage.isEmpty()) { + validateImageFile(certificateImage, "수료증"); + } + + Integer currentCardinal = getCurrentCardinalValue(); + MemberRole role = request.getCardinal() >= currentCardinal ? MemberRole.YB : MemberRole.OB; + + String profileImageUrl = s3Service.uploadFile(profileImage, "profile"); + String certificateImageUrl = null; + if (certificateImage != null && !certificateImage.isEmpty()) { + certificateImageUrl = s3Service.uploadFile(certificateImage, "certificate"); + } + + String encodedPassword = bCryptPasswordEncoder.encode(request.getPassword()); + + Member member = Member.builder() + .id(request.getId()) + .password(encodedPassword) + .name(request.getName()) + .phone(normalizePhone(request.getPhone())) + .cardinal(request.getCardinal()) + .part(Part.valueOf(request.getPart())) + .profileImageUrl(profileImageUrl) + .certificateImageUrl(certificateImageUrl) + .email(request.getEmail()) + .status(MemberStatus.PENDING) + .role(role) + .build(); + + memberRepository.save(member); + return member.getUserId(); + } + + @Transactional + public SignInResponse signin(SignInRequest request) { + Member member = memberRepository.findById(request.getId()) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 아이디입니다.")); + + if (member.getStatus() == MemberStatus.PENDING) { + throw new IllegalArgumentException("관리자 승인 대기 중인 계정입니다. 승인 후 로그인해 주세요."); + } + + if (!bCryptPasswordEncoder.matches(request.getPassword(), member.getPassword())) { + throw new IllegalArgumentException("비밀번호가 올바르지 않습니다."); + } + + // 기수 변경 시 역할 재판별 + MemberRole role = member.getRole() != null ? member.getRole() : MemberRole.OB; + if (member.getCardinal() != null) { + Integer currentCardinal = getCurrentCardinalValue(); + role = member.getCardinal() >= currentCardinal ? MemberRole.YB : MemberRole.OB; + if (member.getRole() != role) { + member.updateRole(role); + } + } + + String accessToken = jwtTokenProvider.makeJwtToken(member.getUserId()); + String refreshToken = jwtTokenProvider.makeRefreshToken(member.getUserId()); + member.updateRefreshToken(hashToken(refreshToken)); + + String redirectPath = role == MemberRole.YB ? "/mypage" : "/mypage/ob/profile"; + + return new SignInResponse(accessToken, refreshToken, role, redirectPath); + } - public Long save(SignUpRequest request) { + public Map checkIdDuplicate(String id) { + if (id == null || id.isBlank()) { + throw new IllegalArgumentException("아이디를 먼저 입력해 주세요."); + } + boolean isDuplicate = memberRepository.existsById(id); + String message = isDuplicate ? "이미 사용 중인 아이디입니다." : "사용 가능한 아이디입니다."; + return Map.of("available", !isDuplicate, "message", message); + } - // pw 암호화 - String encodingPassword = bCryptPasswordEncoder.encode(request.getPassword()); + @Transactional + public SignInResponse refreshToken(String refreshToken) { + String hashedToken = hashToken(refreshToken); + Member member = memberRepository.findByRefreshToken(hashedToken) + .orElseThrow(() -> new IllegalArgumentException("유효하지 않은 리프레시 토큰입니다.")); + + try { + jwtTokenProvider.validateToken(refreshToken); + } catch (Exception e) { + member.clearRefreshToken(); + throw new IllegalArgumentException("리프레시 토큰이 만료되었습니다. 다시 로그인해 주세요."); + } - Member user = SignUpRequest.from(request, encodingPassword); + String newAccessToken = jwtTokenProvider.makeJwtToken(member.getUserId()); + String newRefreshToken = jwtTokenProvider.makeRefreshToken(member.getUserId()); + member.updateRefreshToken(hashToken(newRefreshToken)); - memberRepository.save(user); - return user.getUserId(); + String redirectPath = member.getRole() == MemberRole.YB ? "/mypage" : "/mypage/ob/profile"; + + return new SignInResponse(newAccessToken, newRefreshToken, member.getRole(), redirectPath); } - public String signin(SignInRequest request) { + public CurrentCardinalResponse getCurrentCardinal() { + Integer cardinal = getCurrentCardinalValue(); + return new CurrentCardinalResponse(cardinal); + } - Member user = memberRepository.findById(request.getId()) - .orElseThrow(() -> new IllegalArgumentException("가입된 아이디가 아닙니다.")); + public List getPendingMembers() { + return memberRepository.findAllByStatus(MemberStatus.PENDING); + } - // 패스워드 일치 여부 확인 - passwordMustBeSame(request.getPassword(), user.getPassword()); + private Integer getCurrentCardinalValue() { + return currentCardinalRepository.findById(1L) + .map(CurrentCardinal::getCardinal) + .orElse(33); + } + + private void validateSignUpRequest(SignUpRequest request, MultipartFile certificateImage) { + if (request.getId() == null || request.getId().isBlank()) { + throw new IllegalArgumentException("아이디를 입력해 주세요."); + } + if (request.getPassword() == null || !PASSWORD_PATTERN.matcher(request.getPassword()).matches()) { + throw new IllegalArgumentException("영문, 숫자, 특수문자를 포함해 8자 이상이어야 합니다."); + } + if (!request.getPassword().equals(request.getPasswordConfirm())) { + throw new IllegalArgumentException("비밀번호가 일치하지 않습니다."); + } + if (request.getName() == null || request.getName().isBlank()) { + throw new IllegalArgumentException("이름을 입력해 주세요."); + } + if (request.getPhone() == null || !PHONE_PATTERN.matcher(normalizePhone(request.getPhone())).matches()) { + throw new IllegalArgumentException("올바른 휴대폰 번호를 입력해 주세요."); + } + if (request.getCardinal() == null) { + throw new IllegalArgumentException("활동 기수를 선택해 주세요."); + } + if (request.getPart() == null || request.getPart().isBlank()) { + throw new IllegalArgumentException("파트를 선택해 주세요."); + } + try { + Part.valueOf(request.getPart()); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("파트를 선택해 주세요."); + } - // user pk로 토큰 생성 - String token = jwtTokenProvider.makeJwtToken(user.getUserId()); + if (request.getEmail() == null || request.getEmail().isBlank()) { + throw new IllegalArgumentException("이메일을 입력해 주세요."); + } - return token; + if (request.getCardinal() <= 33 && (certificateImage == null || certificateImage.isEmpty())) { + throw new IllegalArgumentException("33기 이하 회원은 수료증을 업로드해 주세요."); + } } - private void passwordMustBeSame(String requestPassword, String password) { - if (!bCryptPasswordEncoder.matches(requestPassword, password)) { - throw new IllegalArgumentException(); + private void validateImageFile(MultipartFile file, String fieldName) { + if (file == null || file.isEmpty()) { + throw new IllegalArgumentException(fieldName + "을(를) 업로드해 주세요."); + } + if (file.getSize() > MAX_FILE_SIZE) { + throw new IllegalArgumentException("10MB 이하의 이미지만 업로드 가능합니다."); } + String originalFilename = file.getOriginalFilename(); + if (originalFilename == null) { + throw new IllegalArgumentException("이미지 파일(jpg, png)만 업로드 가능합니다."); + } + String extension = originalFilename.substring(originalFilename.lastIndexOf(".") + 1).toLowerCase(); + if (!ALLOWED_EXTENSIONS.contains(extension)) { + throw new IllegalArgumentException("이미지 파일(jpg, png)만 업로드 가능합니다."); + } + } + + private String normalizePhone(String phone) { + return phone.replaceAll("[^0-9]", ""); } + private String hashToken(String token) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(token.getBytes(StandardCharsets.UTF_8)); + StringBuilder sb = new StringBuilder(); + for (byte b : hash) { + sb.append(String.format("%02x", b)); + } + return sb.toString(); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException(e); + } + } } diff --git a/src/main/java/com/kusitms/website/domain/user/MemberStatus.java b/src/main/java/com/kusitms/website/domain/user/MemberStatus.java new file mode 100644 index 0000000..dec72b5 --- /dev/null +++ b/src/main/java/com/kusitms/website/domain/user/MemberStatus.java @@ -0,0 +1,6 @@ +package com.kusitms.website.domain.user; + +public enum MemberStatus { + PENDING, + APPROVED +} diff --git a/src/main/java/com/kusitms/website/domain/user/Part.java b/src/main/java/com/kusitms/website/domain/user/Part.java new file mode 100644 index 0000000..f61ca2b --- /dev/null +++ b/src/main/java/com/kusitms/website/domain/user/Part.java @@ -0,0 +1,8 @@ +package com.kusitms.website.domain.user; + +public enum Part { + PLANNER, + DESIGNER, + FRONTEND, + BACKEND +} diff --git a/src/main/java/com/kusitms/website/domain/user/dto/request/SignUpRequest.java b/src/main/java/com/kusitms/website/domain/user/dto/request/SignUpRequest.java index 7d8a657..761af2c 100644 --- a/src/main/java/com/kusitms/website/domain/user/dto/request/SignUpRequest.java +++ b/src/main/java/com/kusitms/website/domain/user/dto/request/SignUpRequest.java @@ -1,18 +1,34 @@ package com.kusitms.website.domain.user.dto.request; -import com.kusitms.website.domain.user.Member; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Getter; +import lombok.Setter; @Getter +@Setter +@Schema(description = "회원가입 요청") public class SignUpRequest { - + @Schema(description = "아이디") private String id; + + @Schema(description = "비밀번호") private String password; - public static Member from(SignUpRequest request, String encodingPassword) { - return Member.builder() - .id(request.getId()) - .password(encodingPassword) - .build(); - } + @Schema(description = "비밀번호 확인") + private String passwordConfirm; + + @Schema(description = "이름") + private String name; + + @Schema(description = "휴대폰 번호") + private String phone; + + @Schema(description = "활동 기수") + private Integer cardinal; + + @Schema(description = "파트 (PLANNER, DESIGNER, FRONTEND, BACKEND)") + private String part; + + @Schema(description = "이메일") + private String email; } diff --git a/src/main/java/com/kusitms/website/domain/user/dto/response/CurrentCardinalResponse.java b/src/main/java/com/kusitms/website/domain/user/dto/response/CurrentCardinalResponse.java new file mode 100644 index 0000000..eb6e8aa --- /dev/null +++ b/src/main/java/com/kusitms/website/domain/user/dto/response/CurrentCardinalResponse.java @@ -0,0 +1,13 @@ +package com.kusitms.website.domain.user.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +@Schema(description = "현재 기수 응답") +public class CurrentCardinalResponse { + @Schema(description = "현재 활동 기수") + private Integer currentCardinal; +} diff --git a/src/main/java/com/kusitms/website/domain/user/dto/response/SignInResponse.java b/src/main/java/com/kusitms/website/domain/user/dto/response/SignInResponse.java new file mode 100644 index 0000000..d8a7ac5 --- /dev/null +++ b/src/main/java/com/kusitms/website/domain/user/dto/response/SignInResponse.java @@ -0,0 +1,23 @@ +package com.kusitms.website.domain.user.dto.response; + +import com.kusitms.website.domain.user.MemberRole; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +@Schema(description = "로그인 응답") +public class SignInResponse { + @Schema(description = "Access Token") + private String accessToken; + + @Schema(description = "Refresh Token") + private String refreshToken; + + @Schema(description = "사용자 역할 (YB/OB)") + private MemberRole role; + + @Schema(description = "리다이렉트 경로") + private String redirectPath; +} diff --git a/src/main/java/com/kusitms/website/global/auth/UserPrincipal.java b/src/main/java/com/kusitms/website/global/auth/UserPrincipal.java index da18197..e35da44 100644 --- a/src/main/java/com/kusitms/website/global/auth/UserPrincipal.java +++ b/src/main/java/com/kusitms/website/global/auth/UserPrincipal.java @@ -1,34 +1,47 @@ package com.kusitms.website.global.auth; import com.kusitms.website.domain.user.Member; +import com.kusitms.website.domain.user.MemberRole; import lombok.Getter; import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; import java.util.Collection; +import java.util.Collections; +import java.util.List; @Getter public class UserPrincipal implements UserDetails { private Long pk; private String id; private String paswword; + private Collection authorities; - public UserPrincipal(Long pk, String id) { + public UserPrincipal(Long pk, String id, Collection authorities) { this.pk = pk; this.id = id; + this.authorities = authorities; } public static UserPrincipal create(Member user) { + List authorities; + if (user.getRole() == MemberRole.ADMIN) { + authorities = List.of(new SimpleGrantedAuthority("ROLE_ADMIN")); + } else { + authorities = Collections.emptyList(); + } return new UserPrincipal( user.getUserId(), - user.getId() + user.getId(), + authorities ); } @Override public Collection getAuthorities() { - return null; + return authorities; } @Override diff --git a/src/main/java/com/kusitms/website/global/auth/jwt/JwtTokenProvider.java b/src/main/java/com/kusitms/website/global/auth/jwt/JwtTokenProvider.java index d3f84e4..a7f62bb 100644 --- a/src/main/java/com/kusitms/website/global/auth/jwt/JwtTokenProvider.java +++ b/src/main/java/com/kusitms/website/global/auth/jwt/JwtTokenProvider.java @@ -22,12 +22,12 @@ @RequiredArgsConstructor public class JwtTokenProvider { - // 1시간 - private final long TOKEN_VALID_MILISECOND = 1000L * 60 * 60; + private static final long ACCESS_TOKEN_VALIDITY = 1000L * 60 * 60; // 1시간 + private static final long REFRESH_TOKEN_VALIDITY = 1000L * 60 * 60 * 24 * 7; // 7일 private final UserDetailsService userDetailsService; - @Value("spring.jwt.secretkey") + @Value("${spring.jwt.secretkey}") private String secretKey; @PostConstruct @@ -36,24 +36,37 @@ protected void init() { } public String makeJwtToken(Long userId) { + return createToken(userId, ACCESS_TOKEN_VALIDITY, "access"); + } + + public String makeRefreshToken(Long userId) { + return createToken(userId, REFRESH_TOKEN_VALIDITY, "refresh"); + } + + private String createToken(Long userId, long validity, String tokenType) { Claims claims = Jwts.claims().setSubject(Long.toString(userId)); + claims.put("type", tokenType); Date now = new Date(); return Jwts.builder() .setIssuedAt(now) - .setExpiration(new Date(now.getTime() + TOKEN_VALID_MILISECOND)) + .setExpiration(new Date(now.getTime() + validity)) .setClaims(claims) .signWith(SignatureAlgorithm.HS512, secretKey) .compact(); } - // 인증 성공시 SecurityContextHolder에 저장할 Authentication 객체 생성 public Authentication getAuthentication(String token) { - UserDetails userDetails = userDetailsService.loadUserByUsername(String.valueOf(this.getUserPk(token))); + Claims claims = Jwts.parser().setSigningKey(secretKey) + .parseClaimsJws(token).getBody(); + String tokenType = claims.get("type", String.class); + if (!"access".equals(tokenType)) { + throw new IllegalArgumentException("액세스 토큰이 아닙니다."); + } + UserDetails userDetails = userDetailsService.loadUserByUsername(claims.getSubject()); return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities()); } - // Jwt Token에서 User PK 추출 public Long getUserPk(String token) { Claims claims = Jwts.parser().setSigningKey(secretKey) .parseClaimsJws(token).getBody(); @@ -69,7 +82,6 @@ public String getJwtFromRequest(HttpServletRequest request) { return null; } - // Jwt Token의 유효성 및 만료 기간 검사 public boolean validateToken(String jwtToken) { Jwts.parser().setSigningKey(secretKey).parseClaimsJws(jwtToken); return true; diff --git a/src/main/java/com/kusitms/website/global/config/OpenApiConfig.java b/src/main/java/com/kusitms/website/global/config/OpenApiConfig.java index 8420ac7..49e7747 100644 --- a/src/main/java/com/kusitms/website/global/config/OpenApiConfig.java +++ b/src/main/java/com/kusitms/website/global/config/OpenApiConfig.java @@ -3,6 +3,8 @@ import io.swagger.v3.oas.models.Components; import io.swagger.v3.oas.models.OpenAPI; import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -10,12 +12,22 @@ public class OpenApiConfig { @Bean public OpenAPI openAPI() { + String securitySchemeName = "Bearer Authentication"; + Info info = new Info() .title("KUSTIMS 공식 홈페이지 API Document") .version("v0.0.1") .description("KUSITMS 공식 홈페이지 프로젝트의 API 명세서"); + + SecurityScheme securityScheme = new SecurityScheme() + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT"); + return new OpenAPI() - .components(new Components()) + .components(new Components() + .addSecuritySchemes(securitySchemeName, securityScheme)) + .addSecurityItem(new SecurityRequirement().addList(securitySchemeName)) .info(info); } -} +} \ No newline at end of file diff --git a/src/main/java/com/kusitms/website/global/config/SecurityConfig.java b/src/main/java/com/kusitms/website/global/config/SecurityConfig.java index 542c5ef..796c6b6 100644 --- a/src/main/java/com/kusitms/website/global/config/SecurityConfig.java +++ b/src/main/java/com/kusitms/website/global/config/SecurityConfig.java @@ -7,6 +7,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.http.SessionCreationPolicy; @@ -21,6 +22,7 @@ @Configuration @EnableWebSecurity +@EnableGlobalMethodSecurity(prePostEnabled = true) @RequiredArgsConstructor public class SecurityConfig { private final JwtTokenProvider jwtTokenProvider; diff --git a/src/main/java/com/kusitms/website/global/config/WebConfig.java b/src/main/java/com/kusitms/website/global/config/WebConfig.java index eeedf80..db99358 100644 --- a/src/main/java/com/kusitms/website/global/config/WebConfig.java +++ b/src/main/java/com/kusitms/website/global/config/WebConfig.java @@ -10,7 +10,8 @@ public class WebConfig implements WebMvcConfigurer { public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOrigins("*") - .allowedMethods("GET") + .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") + .allowedHeaders("*") .maxAge(3000); } } diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 47bbd79..50fd228 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -21,3 +21,11 @@ springdoc.swagger-ui.operations-sorter=alpha springdoc.api-docs.path=/api-docs/json springdoc.api-docs.groups.enabled=true springdoc.cache.disabled=true + +# Mail +spring.mail.host=${MAIL_HOST:smtp.gmail.com} +spring.mail.port=${MAIL_PORT:587} +spring.mail.username=${MAIL_USERNAME:} +spring.mail.password=${MAIL_PASSWORD:} +spring.mail.properties.mail.smtp.auth=true +spring.mail.properties.mail.smtp.starttls.enable=true diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties index 639fbf8..33043a2 100644 --- a/src/test/resources/application.properties +++ b/src/test/resources/application.properties @@ -11,4 +11,22 @@ spring.jpa.properties.hibernate.default_batch_fetch_size=100 # multipart spring.servlet.multipart.max-file-size=100MB -spring.servlet.multipart.max-request-size=100MB \ No newline at end of file +spring.servlet.multipart.max-request-size=100MB + +# AWS +cloud.aws.credentials.access-key=test +cloud.aws.credentials.secret-key=test +cloud.aws.region.static=ap-northeast-2 +cloud.aws.s3.bucket=test +cloud.aws.stack.auto=false +cloud.aws.credentials.instance-profile=false +aws.s3.prefix=test + +# JWT +spring.jwt.secretkey=testsecretkeytestsecretkeytestsecretkey + +# Mail +spring.mail.host=smtp.gmail.com +spring.mail.port=587 +spring.mail.username=test +spring.mail.password=test \ No newline at end of file