diff --git a/build.gradle b/build.gradle index b10098e..ba54301 100644 --- a/build.gradle +++ b/build.gradle @@ -30,11 +30,12 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-jdbc' implementation 'org.springframework.boot:spring-boot-starter-data-redis' implementation 'com.jcraft:jsch:0.1.55' + implementation 'org.springframework.boot:spring-boot-starter-mail' implementation 'org.springframework.boot:spring-boot-starter-data-redis' - implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.1.0' implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310' // jwt implementation group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.5' diff --git a/src/main/java/io/oopy/coding/common/config/RedisConfig.java b/src/main/java/io/oopy/coding/common/config/RedisConfig.java index c6fd497..ff3a78d 100644 --- a/src/main/java/io/oopy/coding/common/config/RedisConfig.java +++ b/src/main/java/io/oopy/coding/common/config/RedisConfig.java @@ -1,5 +1,6 @@ package io.oopy.coding.common.config; +import io.oopy.coding.common.redis.organizationcert.OrganizationCertification; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -7,7 +8,10 @@ import org.springframework.data.redis.connection.RedisStandaloneConfiguration; import org.springframework.data.redis.connection.lettuce.LettuceClientConfiguration; import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.repository.configuration.EnableRedisRepositories; +import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.StringRedisSerializer; @Configuration @EnableRedisRepositories @@ -24,4 +28,13 @@ public RedisConnectionFactory redisConnectionFactory() { LettuceClientConfiguration clientConfig = LettuceClientConfiguration.builder().build(); return new LettuceConnectionFactory(config, clientConfig); } + + @Bean + public RedisTemplate redisTemplate() { + RedisTemplate redisTemplate = new RedisTemplate<>(); + redisTemplate.setConnectionFactory(redisConnectionFactory()); + redisTemplate.setKeySerializer(new StringRedisSerializer()); // Key: String + redisTemplate.setValueSerializer(new Jackson2JsonRedisSerializer<>(OrganizationCertification.class)); // Value: 직렬화에 사용할 Object 사용하기 + return redisTemplate; + } } \ No newline at end of file diff --git a/src/main/java/io/oopy/coding/common/config/security/SecurityConfig.java b/src/main/java/io/oopy/coding/common/config/security/SecurityConfig.java index 29ef02d..4e68c46 100644 --- a/src/main/java/io/oopy/coding/common/config/security/SecurityConfig.java +++ b/src/main/java/io/oopy/coding/common/config/security/SecurityConfig.java @@ -42,7 +42,8 @@ public class SecurityConfig { "/api/v1/auth/login/**", "/api/v1/auth/signup", "/api/v1/contents/get", "api/v1/comments/get", "/api/v1/profile/**", - "/login/oauth2/**", "/api/v1/feed/title", "/api/v1/feed/body", "/api/v1/feed/nickname" + "/login/oauth2/**", "/api/v1/feed/title", "/api/v1/feed/body", + "/api/v1/email-cert/cert/**", "/api/v1/feed/nickname" }; @Bean diff --git a/src/main/java/io/oopy/coding/common/constant/EmailConstant.java b/src/main/java/io/oopy/coding/common/constant/EmailConstant.java new file mode 100644 index 0000000..ccae864 --- /dev/null +++ b/src/main/java/io/oopy/coding/common/constant/EmailConstant.java @@ -0,0 +1,41 @@ +package io.oopy.coding.common.constant; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class EmailConstant { + public static final Integer EMAIL_EXPIRED_PERIOD_SECONDS = 600; // 임시 10분 + public static final String CERT_TITLE = String.format("[%s] 조직 이메일 인증", GlobalConstant.PROJECT_NAME); + public static final String CERT_CONTENT = "\n" + + "\n" + + "\n" + + "\n" + + "\n" + + "\n" + + "
인증하기
\n" + + "\n" + + "\n" + + ""; +} diff --git a/src/main/java/io/oopy/coding/common/constant/GlobalConstant.java b/src/main/java/io/oopy/coding/common/constant/GlobalConstant.java new file mode 100644 index 0000000..15a2ee9 --- /dev/null +++ b/src/main/java/io/oopy/coding/common/constant/GlobalConstant.java @@ -0,0 +1,9 @@ +package io.oopy.coding.common.constant; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class GlobalConstant { + public static final String PROJECT_NAME = "팔만코딩경"; +} diff --git a/src/main/java/io/oopy/coding/common/email/dto/EmailSend.java b/src/main/java/io/oopy/coding/common/email/dto/EmailSend.java new file mode 100644 index 0000000..35efeb9 --- /dev/null +++ b/src/main/java/io/oopy/coding/common/email/dto/EmailSend.java @@ -0,0 +1,22 @@ +package io.oopy.coding.common.email.dto; + +import lombok.*; + +@Getter +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class EmailSend { + + private String targetEmail; + @Setter + private String title; + @Setter + private String content; + + public static EmailSend of(String targetEmail, String title, String content) { + return new EmailSend(targetEmail, + title, + content + ); + } +} diff --git a/src/main/java/io/oopy/coding/common/email/service/EmailService.java b/src/main/java/io/oopy/coding/common/email/service/EmailService.java new file mode 100644 index 0000000..10c7144 --- /dev/null +++ b/src/main/java/io/oopy/coding/common/email/service/EmailService.java @@ -0,0 +1,29 @@ +package io.oopy.coding.common.email.service; + +import io.oopy.coding.common.email.dto.EmailSend; +import jakarta.mail.Message; +import jakarta.mail.MessagingException; +import jakarta.mail.internet.MimeMessage; +import lombok.RequiredArgsConstructor; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.stereotype.Service; + +/** + * email db를 만들 가능성이 있으므로, service + */ +@Service +@RequiredArgsConstructor +public class EmailService { + + private final JavaMailSender javaMailSender; + private static final String MAIL_CHARACTER_SET = "utf-8"; + private static final String MAIL_SUB_TYPE = "html"; + + public void sendMail(EmailSend emailSend) throws MessagingException { + MimeMessage message = javaMailSender.createMimeMessage(); + message.addRecipients(Message.RecipientType.TO, emailSend.getTargetEmail()); + message.setSubject(emailSend.getTitle()); + message.setText(emailSend.getContent(), MAIL_CHARACTER_SET, MAIL_SUB_TYPE); + javaMailSender.send(message); + } +} diff --git a/src/main/java/io/oopy/coding/common/redis/organizationcert/OrganizationCertification.java b/src/main/java/io/oopy/coding/common/redis/organizationcert/OrganizationCertification.java new file mode 100644 index 0000000..29ba41b --- /dev/null +++ b/src/main/java/io/oopy/coding/common/redis/organizationcert/OrganizationCertification.java @@ -0,0 +1,48 @@ +package io.oopy.coding.common.redis.organizationcert; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer; +import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer; +import lombok.*; + +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; + +@Getter +@NoArgsConstructor +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@EqualsAndHashCode +public class OrganizationCertification { + + private String id; + private String certToken; + private String userEmail; + private String organizationCode; + private String organizationName; + @JsonSerialize(using = LocalDateTimeSerializer.class) + @JsonDeserialize(using = LocalDateTimeDeserializer.class) + private LocalDateTime expiredAt; + private Boolean expired; + + @Builder + private OrganizationCertification(String certToken, String id, String userEmail, + String organizationCode, String organizationName, long ttl) { + this.certToken = certToken; + this.id = id; + this.userEmail = userEmail; + this.organizationCode = organizationCode; + this.organizationName = organizationName; + this.expiredAt = LocalDateTime.now().plus(ttl, ChronoUnit.SECONDS); + } + + public static OrganizationCertification of(String certToken, String id, String userEmail, + String organizationCode, String organizationName, long ttl) { + return new OrganizationCertification(certToken, id, userEmail, + organizationCode, organizationName, ttl); + } + + public boolean isExpired() { + return this.expiredAt.isAfter(LocalDateTime.now()); + } +} diff --git a/src/main/java/io/oopy/coding/common/redis/organizationcert/OrganizationCertificationRepository.java b/src/main/java/io/oopy/coding/common/redis/organizationcert/OrganizationCertificationRepository.java new file mode 100644 index 0000000..c5356bb --- /dev/null +++ b/src/main/java/io/oopy/coding/common/redis/organizationcert/OrganizationCertificationRepository.java @@ -0,0 +1,6 @@ +package io.oopy.coding.common.redis.organizationcert; + +import org.springframework.data.repository.CrudRepository; + +public interface OrganizationCertificationRepository extends CrudRepository { +} diff --git a/src/main/java/io/oopy/coding/common/redis/organizationcert/OrganizationCertificationService.java b/src/main/java/io/oopy/coding/common/redis/organizationcert/OrganizationCertificationService.java new file mode 100644 index 0000000..d8cd91e --- /dev/null +++ b/src/main/java/io/oopy/coding/common/redis/organizationcert/OrganizationCertificationService.java @@ -0,0 +1,117 @@ +package io.oopy.coding.common.redis.organizationcert; + +import io.oopy.coding.common.redis.organizationcert.dto.OrganizationCertificationDto; +import io.oopy.coding.common.redis.organizationcert.dto.OrganizationCertKey; +import io.oopy.coding.common.redis.organizationcert.dto.OrganizationKey; +import io.oopy.coding.organization.service.OrganizationService; +import io.oopy.coding.organization.service.dto.OrganizationDto; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.ListOperations; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.ObjectUtils; + +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +/** + * 조직 인증 관련 서비스. Redis를 사용 + */ +@Service +@Slf4j +public class OrganizationCertificationService { + + private final ListOperations listOperations; + private final RedisTemplate redisTemplate; + private final OrganizationService organizationService; + private static final long EXPIRED_TIME = 600; + + public OrganizationCertificationService(RedisTemplate redisTemplate, + OrganizationService organizationService) { + this.listOperations = redisTemplate.opsForList(); + this.redisTemplate = redisTemplate; + this.organizationService = organizationService; + } + + /** + * 인증 진행중인 조직 정보 리스트 조회 + * @param organizationCertKey + * @return + */ + @Transactional(readOnly = true) + public List getOrganizationCertifications(OrganizationKey organizationCertKey) { + List organizationCertifications = listOperations.range(organizationCertKey.getKey(), 0, -1); + if (ObjectUtils.isEmpty(organizationCertifications)) + return new ArrayList<>(); + List filteredOrganizationCertifications = organizationCertifications.stream() + .filter(v -> v.isExpired()) + .collect(Collectors.toList()); + return ObjectUtils.isEmpty(filteredOrganizationCertifications) ? + new ArrayList<>() : + filteredOrganizationCertifications; + } + + /** + * 조직 인증 등록. 만료시간 : 600초 + * @param register + */ + @Transactional + public void register(OrganizationCertificationDto.Register register) { + if(organizationService.isUserAlreadyRegistered( + OrganizationDto.Cert.of(register.getOrganizationCode(), + Long.parseLong(register.userId()), + register.userEmail() + ))) { + throw new RuntimeException("이미 인증된 조직입니다."); + } + Optional checkOrganizationCertification = getOrganizationCertification(register); + if (checkOrganizationCertification.isPresent()) { + throw new RuntimeException("인증 진행중입니다."); + } + OrganizationCertification organizationCertification = OrganizationCertification.of(register.token(), + register.userId(), + register.userEmail(), + register.code(), + register.organizationName(), + EXPIRED_TIME + ); + listOperations.rightPush(register.getKey(), organizationCertification); + ZoneId zoneId = ZoneId.systemDefault(); + redisTemplate.expireAt(register.getKey(), + organizationCertification.getExpiredAt().atZone(zoneId).toInstant()); + } + + /** + * 조직 인증 삭제 + * @param organizationCertKey + */ + @Transactional + public void delete(OrganizationCertKey organizationCertKey) { + Optional organizationCertification = getOrganizationCertification(organizationCertKey); + if (organizationCertification.isEmpty()) { + throw new RuntimeException("인증 내역 없음."); + } + listOperations.remove(organizationCertKey.getKey(), 0, organizationCertification.get()); + } + + /** + * 진행 중인 조직 정보 리스트 중에, 특정 조직 하나를 뽑아낸다. + * @param cert + * @return + */ + public Optional getOrganizationCertification(OrganizationCertKey cert) { + List organizationCertificationList = getOrganizationCertifications(cert); + if (ObjectUtils.isEmpty(organizationCertificationList)) { + return Optional.empty(); + } + Optional organizationCertification = organizationCertificationList.stream() + .filter(v -> v.getOrganizationCode() != null && + v.getOrganizationCode().equals(cert.getOrganizationCode())) + .findFirst(); + return organizationCertification; + } +} \ No newline at end of file diff --git a/src/main/java/io/oopy/coding/common/redis/organizationcert/dto/OrganizationCertKey.java b/src/main/java/io/oopy/coding/common/redis/organizationcert/dto/OrganizationCertKey.java new file mode 100644 index 0000000..3833402 --- /dev/null +++ b/src/main/java/io/oopy/coding/common/redis/organizationcert/dto/OrganizationCertKey.java @@ -0,0 +1,5 @@ +package io.oopy.coding.common.redis.organizationcert.dto; + +public interface OrganizationCertKey extends OrganizationKey{ + String getOrganizationCode(); +} diff --git a/src/main/java/io/oopy/coding/common/redis/organizationcert/dto/OrganizationCertificationDto.java b/src/main/java/io/oopy/coding/common/redis/organizationcert/dto/OrganizationCertificationDto.java new file mode 100644 index 0000000..859725e --- /dev/null +++ b/src/main/java/io/oopy/coding/common/redis/organizationcert/dto/OrganizationCertificationDto.java @@ -0,0 +1,120 @@ +package io.oopy.coding.common.redis.organizationcert.dto; + +import jakarta.validation.constraints.NotBlank; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class OrganizationCertificationDto { + + private static final String KEY_PREFIX = "org-cert:"; + + public record Register(String userId, String token, String code, + String organizationName, String userEmail) implements OrganizationCertKey { + @Override + public String getKey() { + return KEY_PREFIX + userId; + } + + @Override + public String getOrganizationCode() { + return code; + } + + public void checkSameToken(String token) { + if(!this.token.equals(token)) { + throw new RuntimeException("token이 일치하지 않습니다."); + } + } + + public static OrganizationCertificationDto.Register of( + @NotBlank(message = "userId must not be empty")String userId, + @NotBlank(message = "token must not be empty") String token, + @NotBlank(message = "code must not be empty") String code, + @NotBlank(message = "organizationName must not be empty") String organizationName, + @NotBlank(message = "email must not be empty") String email + ) { + return new OrganizationCertificationDto.Register(userId, token, code, organizationName, email); + } + } + + public record Cert(String userId, String token, String code) implements OrganizationCertKey { + + @Override + public String getKey() { + return KEY_PREFIX + userId; + } + + @Override + public String getOrganizationCode() { + return code; + } + + public void checkSameToken(String token) { + if(!this.token.equals(token)) { + throw new RuntimeException("token이 일치하지 않습니다."); + } + } + + public static OrganizationCertificationDto.Cert of( + @NotBlank(message = "userId must not be empty")String userId, + @NotBlank(message = "token must not be empty") String token, + @NotBlank(message = "code must not be empty") String code + ) { + return new OrganizationCertificationDto.Cert(userId, token, code); + } + } + + public record Get(String userId, String code) implements OrganizationCertKey { + + public static OrganizationCertificationDto.Get of( + @NotBlank(message = "userId must not be empty") String userId, + @NotBlank(message = "code must not be empty") String code + ) { + return new OrganizationCertificationDto.Get(userId, code); + } + + @Override + public String getKey() { + return KEY_PREFIX + userId; + } + + @Override + public String getOrganizationCode() { + return code; + } + } + + public record Delete(String userId, String code) implements OrganizationCertKey { + + public static OrganizationCertificationDto.Delete of( + @NotBlank(message = "userId must not be empty") String userId, + @NotBlank(message = "code must not be empty") String code + ) { + return new OrganizationCertificationDto.Delete(userId, code); + } + + @Override + public String getKey() { + return KEY_PREFIX + userId; + } + + @Override + public String getOrganizationCode() { + return code; + } + } + + public record Key(String userId) implements OrganizationKey { + public static OrganizationCertificationDto.Key of( + @NotBlank(message = "userId must not be empty") String userId + ) { + return new OrganizationCertificationDto.Key(userId); + } + + @Override + public String getKey() { + return KEY_PREFIX + userId; + } + } +} diff --git a/src/main/java/io/oopy/coding/common/redis/organizationcert/dto/OrganizationKey.java b/src/main/java/io/oopy/coding/common/redis/organizationcert/dto/OrganizationKey.java new file mode 100644 index 0000000..3091d9a --- /dev/null +++ b/src/main/java/io/oopy/coding/common/redis/organizationcert/dto/OrganizationKey.java @@ -0,0 +1,5 @@ +package io.oopy.coding.common.redis.organizationcert.dto; + +public interface OrganizationKey { + String getKey(); +} diff --git a/src/main/java/io/oopy/coding/common/response/code/ErrorCode.java b/src/main/java/io/oopy/coding/common/response/code/ErrorCode.java index ccd4310..9cd361e 100644 --- a/src/main/java/io/oopy/coding/common/response/code/ErrorCode.java +++ b/src/main/java/io/oopy/coding/common/response/code/ErrorCode.java @@ -27,10 +27,12 @@ public enum ErrorCode implements StatusCode { ALREADY_REGISTERED_USER(BAD_REQUEST, "이미 등록된 유저입니다."), ALREADY_LOGIN_USER(BAD_REQUEST, "이미 로그인한 유저입니다."), + ALREADY_ORGANIZATION_CERT(BAD_REQUEST, "이미 인증 진행중인 조직입니다"), EXPIRED_AUTH_CODE(BAD_REQUEST, "인증 시간이 만료되었습니다"), INVALID_AUTH_CODE(BAD_REQUEST, "유효하지 않은 인증 코드입니다"), INVALID_RECEIVER(BAD_REQUEST, "유효하지 않은 수신자입니다"), + INVALID_ORGANIZATION_CERT_TOKEN(BAD_REQUEST, "올바르지 않은 토큰 정보입니다"), /** * 403 FORBIDDEN: 서버에서 요청을 거부한 경우 @@ -44,6 +46,8 @@ public enum ErrorCode implements StatusCode { NULL_POINT_ERROR(NOT_FOUND,"Null Point Exception"), NOT_VALID_ERROR(NOT_FOUND,"유효하지 않은 요청입니다."), NOT_VALID_HEADER_ERROR(NOT_FOUND,"헤더에 데이터가 존재하지 않습니다."), + NOT_FOUND_ORGANIZATION(NOT_FOUND, "존재하지 않는 조직입니다."), + NOT_FOUND_ORGANIZATION_CERT(NOT_FOUND, "진행중인 인증이 존재하지 않습니다."), /** * 500 INTERNAL_SERVER_ERROR: 서버에서 에러가 발생한 경우 diff --git a/src/main/java/io/oopy/coding/common/security/filter/JwtAuthenticationFilter.java b/src/main/java/io/oopy/coding/common/security/filter/JwtAuthenticationFilter.java index 2baaa01..5e2d14a 100644 --- a/src/main/java/io/oopy/coding/common/security/filter/JwtAuthenticationFilter.java +++ b/src/main/java/io/oopy/coding/common/security/filter/JwtAuthenticationFilter.java @@ -56,7 +56,8 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { "/login/oauth2/**", "/api/v1/contents/get", "/api/v1/comments/get", "/favicon.ico", - "/api/v1/feed/title", "/api/v1/feed/body", "/api/v1/feed/nickname" + "/api/v1/feed/title", "/api/v1/feed/body", "/api/v1/feed/nickname", + "/api/v1/email-cert/cert/**" ); @Override diff --git a/src/main/java/io/oopy/coding/common/util/Time.java b/src/main/java/io/oopy/coding/common/util/Time.java new file mode 100644 index 0000000..ddd526b --- /dev/null +++ b/src/main/java/io/oopy/coding/common/util/Time.java @@ -0,0 +1,11 @@ +package io.oopy.coding.common.util; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class Time { + public static int getNowUnixTime() { + return (int) System.currentTimeMillis() / 1000; + } +} diff --git a/src/main/java/io/oopy/coding/domain/comment/entity/Comment.java b/src/main/java/io/oopy/coding/domain/comment/entity/Comment.java index e7fa66b..317a68e 100644 --- a/src/main/java/io/oopy/coding/domain/comment/entity/Comment.java +++ b/src/main/java/io/oopy/coding/domain/comment/entity/Comment.java @@ -3,6 +3,7 @@ import io.oopy.coding.domain.content.entity.Content; import io.oopy.coding.domain.model.Auditable; import io.oopy.coding.domain.user.entity.User; + import jakarta.persistence.*; import lombok.*; diff --git a/src/main/java/io/oopy/coding/domain/content/entity/Content.java b/src/main/java/io/oopy/coding/domain/content/entity/Content.java index b85a98f..b77f405 100644 --- a/src/main/java/io/oopy/coding/domain/content/entity/Content.java +++ b/src/main/java/io/oopy/coding/domain/content/entity/Content.java @@ -3,8 +3,8 @@ import io.oopy.coding.domain.comment.entity.Comment; import io.oopy.coding.domain.model.Auditable; import io.oopy.coding.domain.user.entity.User; -import io.oopy.coding.domain.mark.entity.ContentMark; +import io.oopy.coding.domain.mark.entity.ContentMark; import jakarta.persistence.*; import lombok.*; import java.time.LocalDateTime; diff --git a/src/main/java/io/oopy/coding/domain/organization/entity/UserOrganization.java b/src/main/java/io/oopy/coding/domain/organization/entity/UserOrganization.java index c6229ee..fa45da5 100644 --- a/src/main/java/io/oopy/coding/domain/organization/entity/UserOrganization.java +++ b/src/main/java/io/oopy/coding/domain/organization/entity/UserOrganization.java @@ -1,7 +1,6 @@ package io.oopy.coding.domain.organization.entity; import io.oopy.coding.domain.model.Auditable; -import io.oopy.coding.domain.organization.entity.Organization; import io.oopy.coding.domain.user.entity.User; import jakarta.persistence.*; import lombok.*; diff --git a/src/main/java/io/oopy/coding/domain/organization/repository/OrganizationRepository.java b/src/main/java/io/oopy/coding/domain/organization/repository/OrganizationRepository.java index a43711f..9164a5e 100644 --- a/src/main/java/io/oopy/coding/domain/organization/repository/OrganizationRepository.java +++ b/src/main/java/io/oopy/coding/domain/organization/repository/OrganizationRepository.java @@ -7,4 +7,4 @@ public interface OrganizationRepository extends JpaRepository { Optional findByCode(String code); -} \ No newline at end of file +} diff --git a/src/main/java/io/oopy/coding/domain/organization/repository/UserOrganizationRepository.java b/src/main/java/io/oopy/coding/domain/organization/repository/UserOrganizationRepository.java index d4cce65..5ffae6d 100644 --- a/src/main/java/io/oopy/coding/domain/organization/repository/UserOrganizationRepository.java +++ b/src/main/java/io/oopy/coding/domain/organization/repository/UserOrganizationRepository.java @@ -8,4 +8,4 @@ public interface UserOrganizationRepository extends JpaRepository { List findByUser(User user); -} \ No newline at end of file +} diff --git a/src/main/java/io/oopy/coding/domain/user/entity/User.java b/src/main/java/io/oopy/coding/domain/user/entity/User.java index 37e9120..99a4eac 100644 --- a/src/main/java/io/oopy/coding/domain/user/entity/User.java +++ b/src/main/java/io/oopy/coding/domain/user/entity/User.java @@ -1,10 +1,10 @@ package io.oopy.coding.domain.user.entity; +import jakarta.persistence.*; +import lombok.*; import io.oopy.coding.domain.model.Auditable; import io.oopy.coding.domain.organization.entity.Organization; import io.oopy.coding.domain.organization.entity.UserOrganization; -import jakarta.persistence.*; -import lombok.*; import java.util.ArrayList; import java.util.List; diff --git a/src/main/java/io/oopy/coding/emailcert/controller/EmailCertController.java b/src/main/java/io/oopy/coding/emailcert/controller/EmailCertController.java new file mode 100644 index 0000000..33d3da3 --- /dev/null +++ b/src/main/java/io/oopy/coding/emailcert/controller/EmailCertController.java @@ -0,0 +1,56 @@ +package io.oopy.coding.emailcert.controller; + +import io.oopy.coding.common.security.authentication.CustomUserDetails; +import io.oopy.coding.emailcert.dto.EmailCertRequest; +import io.oopy.coding.emailcert.service.EmailCertService; +import io.swagger.v3.oas.annotations.Operation; +import jakarta.mail.MessagingException; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.*; + +@Controller +@RequestMapping("/api/v1/email-cert") +@RequiredArgsConstructor +@Slf4j +public class EmailCertController { + + @Value("${mail.cert.success-redirect-url}") + private String successRedirectUrl; + + private final EmailCertService emailCertService; + + @Operation(summary = "조직 인증메일 발송", description = + """ + 조직 인증메일을 발송한다. + 해당 메일에서 인증하기 버튼을 누르면, 인증이 되는 식으로 구현. + """ + ) + @PostMapping("/send") + @ResponseBody + public ResponseEntity sendCertification(@RequestBody @Valid EmailCertRequest.Send emailSend, + @AuthenticationPrincipal CustomUserDetails securityUser) throws MessagingException { + emailCertService.sendCertMail(emailSend.toCertificateEmailSend(securityUser.getUserId().toString())); + return ResponseEntity.noContent().build(); + } + + /** + * 메일에서 인증하기 버튼을 눌렀을 때, 이메일 인증 진행 + * @param userId + * @param token + * @param code + * @param httpServletResponse + * @throws Exception + */ + @GetMapping("/cert/{userId}/{token}/{code}") + public void cert(@PathVariable String userId, @PathVariable String token, @PathVariable String code, HttpServletResponse httpServletResponse) throws Exception{ + emailCertService.cert(userId, token, code); + httpServletResponse.sendRedirect(successRedirectUrl); + } +} diff --git a/src/main/java/io/oopy/coding/emailcert/dto/EmailCertRequest.java b/src/main/java/io/oopy/coding/emailcert/dto/EmailCertRequest.java new file mode 100644 index 0000000..9e74dfb --- /dev/null +++ b/src/main/java/io/oopy/coding/emailcert/dto/EmailCertRequest.java @@ -0,0 +1,33 @@ +package io.oopy.coding.emailcert.dto; + +import io.oopy.coding.emailcert.service.dto.EmailCertificateDto; +import jakarta.validation.constraints.NotBlank; +import lombok.*; + +import static io.oopy.coding.common.constant.EmailConstant.CERT_TITLE; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class EmailCertRequest { + + @Getter + public static class Send { + @NotBlank(message = "이메일은 필수 입력 값입니다.") + private String targetEmail; + @NotBlank(message = "조직 코드는 필수로 존재해야 합니다.") + private String organizationCode; + + public EmailCertificateDto.Send toCertificateEmailSend(String userId) { + return EmailCertificateDto.Send.of(this.targetEmail, + userId, + CERT_TITLE, + null, + this.organizationCode + ); + } + } + + @Getter + public static class Remove { + private String code; + } +} diff --git a/src/main/java/io/oopy/coding/emailcert/service/EmailCertService.java b/src/main/java/io/oopy/coding/emailcert/service/EmailCertService.java new file mode 100644 index 0000000..eb66d83 --- /dev/null +++ b/src/main/java/io/oopy/coding/emailcert/service/EmailCertService.java @@ -0,0 +1,77 @@ +package io.oopy.coding.emailcert.service; + +import io.oopy.coding.common.email.service.EmailService; +import io.oopy.coding.common.redis.organizationcert.OrganizationCertification; +import io.oopy.coding.common.redis.organizationcert.OrganizationCertificationService; +import io.oopy.coding.common.redis.organizationcert.dto.OrganizationCertificationDto; +import io.oopy.coding.common.response.code.ErrorCode; +import io.oopy.coding.common.response.exception.GlobalErrorException; +import io.oopy.coding.emailcert.service.dto.EmailCertificateDto; +import io.oopy.coding.organization.service.OrganizationService; +import io.oopy.coding.organization.service.dto.OrganizationDto; +import jakarta.mail.MessagingException; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.util.Optional; +import java.util.UUID; + +import static io.oopy.coding.common.constant.EmailConstant.CERT_CONTENT; + +@Service +@RequiredArgsConstructor +public class EmailCertService { + + @Value("${mail.cert.domain}") + private String emailCertDomain; + private final OrganizationCertificationService organizationCertificationService; + private final EmailService emailService; + private final OrganizationService organizationService; + + + @Transactional + public void sendCertMail(EmailCertificateDto.Send emailSend) throws MessagingException { + checkSendValid(emailSend); + String token = UUID.randomUUID().toString(); + emailSend.setContent(String.format(CERT_CONTENT, + emailCertDomain, + emailSend.getUserId(), + token, + emailSend.getOrganizationCode() + )); + OrganizationDto.Organization organizationByCode = organizationService.getOrganizationByCode(emailSend.getOrganizationCode()); + organizationCertificationService.register( + new OrganizationCertificationDto.Register( + emailSend.getUserId(), token, emailSend.getOrganizationCode(), + organizationByCode.getName(),emailSend.getTargetEmail() + )); + emailService.sendMail(emailSend); + } + + @Transactional + public void cert(String userId, String token, String code) { + Optional emailCertification = organizationCertificationService.getOrganizationCertification( + OrganizationCertificationDto.Cert.of(userId, token, code)); + if (emailCertification.isEmpty()) { + throw new GlobalErrorException(ErrorCode.NOT_FOUND_ORGANIZATION_CERT); + } + if (!token.equals(emailCertification.get().getCertToken())) { + throw new GlobalErrorException(ErrorCode.INVALID_ORGANIZATION_CERT_TOKEN); + } + organizationService.setUserOrganization( + OrganizationDto.Cert.of(code, Long.parseLong(userId), emailCertification.get().getUserEmail())); + organizationCertificationService.delete( + OrganizationCertificationDto.Delete.of(userId, code) + ); + } + + private void checkSendValid(EmailCertificateDto.Send emailSend) { + Optional emailCertificationBySet = organizationCertificationService.getOrganizationCertification( + OrganizationCertificationDto.Get.of(emailSend.getUserId(), emailSend.getOrganizationCode())); + if (emailCertificationBySet.isPresent()) { + throw new GlobalErrorException(ErrorCode.ALREADY_ORGANIZATION_CERT); + } + } +} diff --git a/src/main/java/io/oopy/coding/emailcert/service/dto/EmailCertificateDto.java b/src/main/java/io/oopy/coding/emailcert/service/dto/EmailCertificateDto.java new file mode 100644 index 0000000..275e2d2 --- /dev/null +++ b/src/main/java/io/oopy/coding/emailcert/service/dto/EmailCertificateDto.java @@ -0,0 +1,32 @@ +package io.oopy.coding.emailcert.service.dto; + +import io.oopy.coding.common.email.dto.EmailSend; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class EmailCertificateDto { + + @Getter + public static class Send extends EmailSend { + + private String userId; + private String organizationCode; + + public Send(String targetEmail, String userId, String title, String content, String organizationCode) { + super(targetEmail, title, content); + this.userId = userId; + this.organizationCode = organizationCode; + } + + public static Send of(String targetEmail, String githubId, String title, String content, String organizationCode) { + return new Send(targetEmail, + githubId, + title, + content, + organizationCode + ); + } + } +} diff --git a/src/main/java/io/oopy/coding/organization/controller/OrganizationController.java b/src/main/java/io/oopy/coding/organization/controller/OrganizationController.java new file mode 100644 index 0000000..feec682 --- /dev/null +++ b/src/main/java/io/oopy/coding/organization/controller/OrganizationController.java @@ -0,0 +1,76 @@ +package io.oopy.coding.organization.controller; + +import io.oopy.coding.common.redis.organizationcert.OrganizationCertification; +import io.oopy.coding.common.redis.organizationcert.OrganizationCertificationService; +import io.oopy.coding.common.redis.organizationcert.dto.OrganizationCertificationDto; +import io.oopy.coding.common.response.SuccessResponse; +import io.oopy.coding.common.security.authentication.CustomUserDetails; +import io.oopy.coding.emailcert.dto.EmailCertRequest; +import io.oopy.coding.organization.dto.OrganizationResponse; +import io.oopy.coding.organization.service.OrganizationService; +import io.oopy.coding.organization.service.dto.OrganizationDto; +import io.swagger.v3.oas.annotations.Operation; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.stream.Collectors; + +@RestController +@RequestMapping("/organization") +@RequiredArgsConstructor +public class OrganizationController { + + private final OrganizationService organizationService; + private final OrganizationCertificationService organizationCertificationService; + + @Operation(summary = "인증된 조직들 내역 조회", description = + """ + 인증된 조직들 내역을 조회한다. + """ + ) + @GetMapping("/users") + public ResponseEntity > > getList(@AuthenticationPrincipal CustomUserDetails securityUser) { + List userOrganizations = organizationService.getUserOrganizations(securityUser.getUserId()); + List response = userOrganizations + .stream() + .map(v->OrganizationResponse.UserOrganization.of(v.getCode(), v.getName(), v.getEmail())) + .collect(Collectors.toList()); + return ResponseEntity.ok(SuccessResponse.from(response)); + } + + @Operation(summary = "인증 진행중인 조직 내역 조회", description = + """ + 인증된 조직들 내역을 조회한다. + """ + ) + @GetMapping("/pending/users") + public ResponseEntity > > getPendingList(@AuthenticationPrincipal CustomUserDetails securityUser) { + OrganizationCertificationDto.Key key = OrganizationCertificationDto.Key.of(securityUser.getUserId().toString()); + List organizationCertifications = organizationCertificationService.getOrganizationCertifications(key); + List response = organizationCertifications + .stream() + .map(v -> OrganizationResponse.UserOrganization.of(v.getOrganizationCode(), v.getOrganizationName(), v.getUserEmail())) + .collect(Collectors.toList()); + return ResponseEntity.ok(SuccessResponse.from(response)); + } + + @Operation(summary = "조직 인증 진행 취소", description = + """ + 조직 인증 진행을 취소한다. + """ + ) + @DeleteMapping("/cert") + public ResponseEntity deleteCert( + @RequestBody EmailCertRequest.Remove cert, + @AuthenticationPrincipal CustomUserDetails securityUser){ + organizationCertificationService.delete( + OrganizationCertificationDto.Delete.of( + securityUser.getUserId().toString(), + cert.getCode() + )); + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/io/oopy/coding/organization/dto/OrganizationResponse.java b/src/main/java/io/oopy/coding/organization/dto/OrganizationResponse.java new file mode 100644 index 0000000..43e276c --- /dev/null +++ b/src/main/java/io/oopy/coding/organization/dto/OrganizationResponse.java @@ -0,0 +1,22 @@ +package io.oopy.coding.organization.dto; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class OrganizationResponse { + + @Getter + @AllArgsConstructor(access = AccessLevel.PRIVATE) + public static class UserOrganization { + private String code; + private String organizationName; + private String userEmail; + + public static UserOrganization of(String code, String organizationName, String userEmail) { + return new UserOrganization(code, organizationName, userEmail); + } + } +} diff --git a/src/main/java/io/oopy/coding/organization/service/OrganizationService.java b/src/main/java/io/oopy/coding/organization/service/OrganizationService.java new file mode 100644 index 0000000..c5f68a1 --- /dev/null +++ b/src/main/java/io/oopy/coding/organization/service/OrganizationService.java @@ -0,0 +1,72 @@ +package io.oopy.coding.organization.service; + +import io.oopy.coding.common.response.code.ErrorCode; +import io.oopy.coding.common.response.exception.GlobalErrorException; +import io.oopy.coding.domain.organization.entity.Organization; +import io.oopy.coding.domain.organization.entity.UserOrganization; +import io.oopy.coding.domain.organization.repository.OrganizationRepository; +import io.oopy.coding.domain.organization.repository.UserOrganizationRepository; +import io.oopy.coding.domain.user.entity.User; +import io.oopy.coding.organization.service.dto.OrganizationDto; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.ObjectUtils; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import static java.util.stream.Collectors.toList; + +@Service +@Slf4j +@RequiredArgsConstructor +public class OrganizationService { + + private final UserOrganizationRepository userOrganizationRepository; + private final OrganizationRepository organizationRepository; + + @Transactional + public OrganizationDto.Organization getOrganizationByCode(String code) { + Organization organization = organizationRepository.findByCode(code).orElseThrow(() -> new GlobalErrorException(ErrorCode.NOT_FOUND_ORGANIZATION)); + return OrganizationDto.Organization.of( + organization.getName(), organization.getCode(), organization.getDescription()); + } + + @Transactional + public List getUserOrganizations(Long userId) { + List userOrganizations = userOrganizationRepository.findByUser(User.builder() + .id(userId).build()); + if (ObjectUtils.isEmpty(userOrganizations)) { + return new ArrayList<>(); + } + List organizations = userOrganizations.stream() + .map(v -> OrganizationDto.UserOrganization.of(v.getOrganization().getName(), + v.getOrganization().getCode(), + v.getOrganization().getDescription(), + v.getEmail() + )) + .collect(toList()); + return organizations; + } + + @Transactional + public void setUserOrganization(OrganizationDto.Cert cert) { + Organization organization = organizationRepository.findByCode(cert.getOrganizationCode()).orElseThrow(()-> new GlobalErrorException(ErrorCode.NOT_FOUND_ORGANIZATION)); + UserOrganization userOrganization = UserOrganization.of(organization, + User.builder() + .id(cert.getUserId()).build(), cert.getUserEmail()); + userOrganizationRepository.save(userOrganization); + } + + public boolean isUserAlreadyRegistered(OrganizationDto.Cert cert) { + List userOrganizations = + getUserOrganizations(cert.getUserId()); + Optional organization = userOrganizations.stream() + .filter(v -> v.getCode().equals(cert.getOrganizationCode())) + .findFirst(); + return organization.isEmpty() ? false : true; + } +} diff --git a/src/main/java/io/oopy/coding/organization/service/dto/OrganizationDto.java b/src/main/java/io/oopy/coding/organization/service/dto/OrganizationDto.java new file mode 100644 index 0000000..48b8211 --- /dev/null +++ b/src/main/java/io/oopy/coding/organization/service/dto/OrganizationDto.java @@ -0,0 +1,64 @@ +package io.oopy.coding.organization.service.dto; + +import jakarta.persistence.Column; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.aspectj.weaver.ast.Or; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class OrganizationDto { + + @Getter + public static class Cert { + private String organizationCode; + private Long userId; + private String userEmail; + + public Cert(String organizationCode, Long userId, String userEmail) { + this.organizationCode = organizationCode; + this.userId = userId; + this.userEmail = userEmail; + } + + public static Cert of(String organizationCode, Long userId, String userEmail) { + return new Cert(organizationCode, userId, userEmail); + } + } + + @Getter + public static class Organization { + private String name; + private String code; + private String description; + + public Organization(String name, String code, String description) { + this.name = name; + this.code = code; + this.description = description; + } + + public static Organization of(String name, String code, String description) { + return new Organization(name, code, description); + } + } + + @Getter + public static class UserOrganization { + private String name; + private String code; + private String description; + private String email; + + public UserOrganization(String name, String code, String description, String email) { + this.name = name; + this.code = code; + this.description = description; + this.email = email; + } + + public static UserOrganization of(String name, String code, String description, String email) { + return new UserOrganization(name, code, description, email); + } + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 04cdaa8..2757ef8 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -18,6 +18,17 @@ spring: max-lifetime: 1200000 # 20m (default 30m) connection-timeout: 3000 # 3s (default 30s) validation-timeout: 2000 # 2s (default 5s) + mail: + host: ${MAIL_HOST} + port: ${MAIL_PORT} + username: ${MAIL_USER_NAME} + password: ${MAIL_PASSWORD} + properties: + mail: + smtp: + auth: true + starttls: + enable: true jpa: database: MYSQL database-platform: org.hibernate.dialect.MySQL8Dialect @@ -71,6 +82,11 @@ ssh: pem: ${SSH_PEM} database-port: ${SSH_DATABASE_PORT} +mail: + cert: + domain: ${MAIL_CERT_DOMAIN} + success-redirect-url: ${MAIL_CERT_SUCCESS_URL:http://www.naver.com} + --- spring.config.activate.on-profile: develop @@ -103,6 +119,17 @@ spring: url: ${DB_URL} username: ${DB_USER_NAME} password: ${DB_PASSWORD} + mail: + host: ${MAIL_HOST} + port: ${MAIL_PORT} + username: ${MAIL_USER_NAME} + password: ${MAIL_PASSWORD} + properties: + mail: + smtp: + auth: true + starttls: + enable: true data.redis: host: localhost port: 6379 @@ -133,3 +160,8 @@ jwt: access-expiration-time: 1800000 # 30m (30 * 60 * 1000) refresh-expiration-time: 604800000 # 7d (7 * 24 * 60 * 60 * 1000) signup-access-expiration-time: 600000 # 10m (10 * 60 * 1000) + +mail: + cert: + domain: ${MAIL_CERT_DOMAIN} + success-redirect-url: ${MAIL_CERT_SUCCESS_URL:http://www.naver.com}