diff --git a/linkmind/build.gradle b/linkmind/build.gradle index 99c38747..9237b5d0 100644 --- a/linkmind/build.gradle +++ b/linkmind/build.gradle @@ -81,6 +81,16 @@ dependencies { // openfeign implementation 'org.springframework.cloud:spring-cloud-starter-openfeign' + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.0.2' + implementation 'org.springframework:spring-test:6.1.1' + + implementation 'com.warrenstrange:googleauth:1.4.0' + implementation 'com.google.zxing:core:3.3.3' + implementation 'com.google.zxing:javase:3.3.3' + + implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' + + } diff --git a/linkmind/src/main/java/com/app/toaster/admin/common/RedirectResponse.java b/linkmind/src/main/java/com/app/toaster/admin/common/RedirectResponse.java new file mode 100644 index 00000000..c2d4c4f1 --- /dev/null +++ b/linkmind/src/main/java/com/app/toaster/admin/common/RedirectResponse.java @@ -0,0 +1,22 @@ +package com.app.toaster.admin.common; + +import com.app.toaster.common.dto.ApiResponse; +import com.app.toaster.exception.Success; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor(access = AccessLevel.PRIVATE) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class RedirectResponse extends ApiResponse { + private final int code; + private final String redirectUrl; + private T data; + + + public static RedirectResponse success(Success success, String redirectUrl, T data){ + return new RedirectResponse<>(success.getHttpStatusCode(), redirectUrl, data); + } +} diff --git a/linkmind/src/main/java/com/app/toaster/admin/config/AdminPasswordEncoder.java b/linkmind/src/main/java/com/app/toaster/admin/config/AdminPasswordEncoder.java new file mode 100644 index 00000000..00197077 --- /dev/null +++ b/linkmind/src/main/java/com/app/toaster/admin/config/AdminPasswordEncoder.java @@ -0,0 +1,23 @@ +package com.app.toaster.admin.config; + +import com.warrenstrange.googleauth.GoogleAuthenticator; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.crypto.factory.PasswordEncoderFactories; +import org.springframework.security.crypto.password.PasswordEncoder; + +@Configuration +public class AdminPasswordEncoder { + + @Bean + public PasswordEncoder passwordEncoder(){ + return PasswordEncoderFactories.createDelegatingPasswordEncoder(); + } + + @Bean + public GoogleAuthenticator googleAuthenticator(){ + return new GoogleAuthenticator(); + } + + +} diff --git a/linkmind/src/main/java/com/app/toaster/admin/config/QrMfaAuthenticator.java b/linkmind/src/main/java/com/app/toaster/admin/config/QrMfaAuthenticator.java new file mode 100644 index 00000000..54007fdd --- /dev/null +++ b/linkmind/src/main/java/com/app/toaster/admin/config/QrMfaAuthenticator.java @@ -0,0 +1,89 @@ +package com.app.toaster.admin.config; + +import com.app.toaster.admin.entity.VerifiedAdmin; +import com.app.toaster.admin.entity.ToasterAdmin; +import com.app.toaster.admin.infrastructure.VerifiedAdminRepository; +import com.app.toaster.exception.Error; +import com.app.toaster.exception.model.CustomException; +import com.google.zxing.BarcodeFormat; +import com.google.zxing.WriterException; +import com.google.zxing.client.j2se.MatrixToImageWriter; +import com.google.zxing.common.BitMatrix; +import com.google.zxing.qrcode.QRCodeWriter; +import com.warrenstrange.googleauth.GoogleAuthenticator; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.mock.web.MockMultipartFile; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.io.IOException; + +@Component +@Slf4j +public class QrMfaAuthenticator { + + private String secret; + private final GoogleAuthenticator googleAuthenticator = new GoogleAuthenticator(); + private final VerifiedAdminRepository verifiedAdminRepository; + + + public QrMfaAuthenticator(@Value("${admin.secret}") final String secret, final VerifiedAdminRepository verifiedAdminRepository) { + this.secret = secret; + this.verifiedAdminRepository = verifiedAdminRepository; + } + + + public MultipartFile generateQrCode(String userKey) { + String data = makeQrDataString(userKey); + QRCodeWriter qrCodeWriter = new QRCodeWriter(); + try { + BitMatrix bitMatrix = qrCodeWriter.encode(data, BarcodeFormat.QR_CODE, 240, 240); + BufferedImage image = MatrixToImageWriter.toBufferedImage(bitMatrix); + ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); + + ImageIO.write(image, "png", byteArrayOutputStream); + byteArrayOutputStream.close(); + + byte[] qrCodeBytes = byteArrayOutputStream.toByteArray(); + + return new MockMultipartFile("qrCode", "qrcode.png", "image/png", qrCodeBytes); + } catch (WriterException | IOException e) { + log.error(e.getMessage()); + } + return null; + } + + private String makeQrDataString(String userKey) { + return "otpauth://totp/toaster?secret=" + userKey + "&issuer=Google"; + } + + public ToasterAdmin verifyGoogleTotpCode(Integer verificationCode, Long id) { + + VerifiedAdmin admin = verifiedAdminRepository.findById(id).orElseThrow( + () -> new CustomException(Error.NOT_FOUND_USER_EXCEPTION, "어드민이 존재하지않는다.") + ); + System.out.println(admin.getAdmin().getUsername()); + + if (verificationCode != null) { + try { + if (!googleAuthenticator.authorize(admin.getOtpSecretKey(), verificationCode)) { + throw new CustomException(Error.BAD_REQUEST_VALIDATION, "유효하지 않은 인증코드입니다."); + } + admin.authorize(); + admin.verifiedAdmin(); + return admin.getAdmin(); + } catch (Exception e) { + log.error("인증 쪽에서 에러 발생."); + } + } else { + throw new CustomException(Error.TOKEN_TIME_EXPIRED_EXCEPTION, "만료된 코드 입니다."); + } + return null; + } + + +} diff --git a/linkmind/src/main/java/com/app/toaster/admin/config/SwaggerConfig.java b/linkmind/src/main/java/com/app/toaster/admin/config/SwaggerConfig.java new file mode 100644 index 00000000..7eb7054a --- /dev/null +++ b/linkmind/src/main/java/com/app/toaster/admin/config/SwaggerConfig.java @@ -0,0 +1,40 @@ +package com.app.toaster.admin.config; + +import io.swagger.v3.oas.annotations.OpenAPIDefinition; +import io.swagger.v3.oas.annotations.info.Info; +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +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; + + +@OpenAPIDefinition( + info = @Info( + title = "토스터 API 명세서입니다.", + description = "어드민 자격이 필요한 것들은 스웨거 문서를 올리지 않겠습니다.", + version = "v1" + ) +) +@Configuration +public class SwaggerConfig { + + private static final String BEARER_TOKEN_PREFIX = "Bearer"; + + @Bean + public OpenAPI openAPI() { + String securityJwtName = "JWT"; + SecurityRequirement securityRequirement = new SecurityRequirement().addList(securityJwtName); + Components components = new Components() + .addSecuritySchemes(securityJwtName, new SecurityScheme() + .name(securityJwtName) + .type(SecurityScheme.Type.HTTP) + .scheme(BEARER_TOKEN_PREFIX) + .bearerFormat(securityJwtName)); + + return new OpenAPI() + .addSecurityItem(securityRequirement) + .components(components); + } +} \ No newline at end of file diff --git a/linkmind/src/main/java/com/app/toaster/admin/controller/AdminController.java b/linkmind/src/main/java/com/app/toaster/admin/controller/AdminController.java new file mode 100644 index 00000000..75fac920 --- /dev/null +++ b/linkmind/src/main/java/com/app/toaster/admin/controller/AdminController.java @@ -0,0 +1,103 @@ +package com.app.toaster.admin.controller; + +import com.app.toaster.admin.common.RedirectResponse; +import com.app.toaster.admin.controller.dto.command.VerifyNewAdminCommand; +import com.app.toaster.admin.controller.dto.request.AdminTotpDto; +import com.app.toaster.admin.controller.dto.request.SignInDto; +import com.app.toaster.admin.controller.dto.response.AdminResponse; +import com.app.toaster.admin.entity.ToasterAdmin; +import com.app.toaster.admin.service.AdminService; +import com.app.toaster.admin.config.QrMfaAuthenticator; +import com.app.toaster.exception.Error; +import com.app.toaster.exception.Success; +import com.app.toaster.exception.model.CustomException; +import com.app.toaster.external.client.aws.S3Service; +import com.app.toaster.external.client.discord.DiscordMessageProvider; + +import com.app.toaster.external.client.discord.NotificationDto; +import com.app.toaster.external.client.discord.NotificationType; + +import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpSession; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; + +@Controller +@RequestMapping("/admin") +@RequiredArgsConstructor +class AdminController { + + private final DiscordMessageProvider discordMessageProvider; + private final S3Service s3Service; + private final QrMfaAuthenticator qrMfaAuthenticator; + private final AdminService adminService; + + @PostMapping("/register") + @ResponseBody + public RedirectResponse registerAdmin(@RequestBody SignInDto signInDto, HttpSession session) { + + VerifyNewAdminCommand res = adminService.registerAdmin(signInDto.username(), signInDto.password()); + + String key = res.key(); + Long adminId = res.id(); + boolean isNewAdmin = res.isNewAdmin(); + + if (isNewAdmin){ + key = executeDiscordQrOperation(key); + } + + session.setAttribute("VerifyId", adminId); + session.setAttribute("QrUrl", key); + + return RedirectResponse.success(Success.LOGIN_SUCCESS, "verify",null); + } + + @GetMapping("/register") + public String getRegisterAdmin(Model model, HttpServletResponse response) throws IOException { + return "basic/register"; + } + + @GetMapping("/verify") + public String responseIsAdminCodeView(Model model) { + return "basic/qrForm"; + } + + @GetMapping("/main") + public String adminMain(Model model) { +// model.addAttribute("imageUrl", imageUrl); // imageUrl을 모델에 추가 + return "basic/admin"; + } + + @PostMapping("/verify-code") + @ResponseBody + public RedirectResponse responseIsAdminView(HttpSession session, @RequestBody AdminTotpDto request) throws IOException { + //admin인지 판단 + Long verifyId = (Long) session.getAttribute("VerifyId"); + + if (verifyId == null) { + throw new CustomException(Error.BAD_REQUEST_ID, "세션에 VerifyId가 없습니다."); + } + + ToasterAdmin toasterAdmin = qrMfaAuthenticator.verifyGoogleTotpCode(Integer.valueOf(request.code()), verifyId); + + if (toasterAdmin == null){ + throw new CustomException(Error.BAD_REQUEST_ID, "잘못된 유저 입니다."); + } + AdminResponse result = new AdminResponse(toasterAdmin.getUsername()); + s3Service.deleteImage((String) session.getAttribute("QrUrl")); + return RedirectResponse.success(Success.LOGIN_SUCCESS,"main", result); + } + + private String executeDiscordQrOperation(String key){ + MultipartFile qrImage = qrMfaAuthenticator.generateQrCode(key); + String imageKey = s3Service.uploadImage(qrImage, "admin/"); + String qrUrl = s3Service.getURL(imageKey); + discordMessageProvider.sendAdmin(new NotificationDto(NotificationType.ADMIN,null, qrUrl)); + return qrUrl; + } +} diff --git a/linkmind/src/main/java/com/app/toaster/admin/controller/dto/command/VerifyNewAdminCommand.java b/linkmind/src/main/java/com/app/toaster/admin/controller/dto/command/VerifyNewAdminCommand.java new file mode 100644 index 00000000..ecb85f80 --- /dev/null +++ b/linkmind/src/main/java/com/app/toaster/admin/controller/dto/command/VerifyNewAdminCommand.java @@ -0,0 +1,8 @@ +package com.app.toaster.admin.controller.dto.command; + +public record VerifyNewAdminCommand( + Long id, + String key, + boolean isNewAdmin +) { +} diff --git a/linkmind/src/main/java/com/app/toaster/admin/controller/dto/request/AdminTotpDto.java b/linkmind/src/main/java/com/app/toaster/admin/controller/dto/request/AdminTotpDto.java new file mode 100644 index 00000000..ee696557 --- /dev/null +++ b/linkmind/src/main/java/com/app/toaster/admin/controller/dto/request/AdminTotpDto.java @@ -0,0 +1,6 @@ +package com.app.toaster.admin.controller.dto.request; + +public record AdminTotpDto( + String code +) { +} diff --git a/linkmind/src/main/java/com/app/toaster/admin/controller/dto/request/SignInDto.java b/linkmind/src/main/java/com/app/toaster/admin/controller/dto/request/SignInDto.java new file mode 100644 index 00000000..5e42414d --- /dev/null +++ b/linkmind/src/main/java/com/app/toaster/admin/controller/dto/request/SignInDto.java @@ -0,0 +1,7 @@ +package com.app.toaster.admin.controller.dto.request; + +public record SignInDto( + String username, + String password +) { +} diff --git a/linkmind/src/main/java/com/app/toaster/admin/controller/dto/response/AdminResponse.java b/linkmind/src/main/java/com/app/toaster/admin/controller/dto/response/AdminResponse.java new file mode 100644 index 00000000..d32cf720 --- /dev/null +++ b/linkmind/src/main/java/com/app/toaster/admin/controller/dto/response/AdminResponse.java @@ -0,0 +1,5 @@ +package com.app.toaster.admin.controller.dto.response; + +public record AdminResponse(String userName) +{ +} diff --git a/linkmind/src/main/java/com/app/toaster/admin/entity/ToasterAdmin.java b/linkmind/src/main/java/com/app/toaster/admin/entity/ToasterAdmin.java new file mode 100644 index 00000000..44890d2d --- /dev/null +++ b/linkmind/src/main/java/com/app/toaster/admin/entity/ToasterAdmin.java @@ -0,0 +1,60 @@ +package com.app.toaster.admin.entity; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ToasterAdmin { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "admin_id") + private Long id; + + @Column(name = "username") + private String username; + + @Column(name = "password") + private String password; + + @Column(name = "verified") + private boolean verified; + + @Column(name = "masterToken") + private String masterToken; + + @Column(name = "lastVerifiedDate") + private LocalDate lastTestDate; + + @Builder + public ToasterAdmin(String username, String password){ + this.username = username; + this.password = password; + this.verified = false; + this.lastTestDate = LocalDate.now(); + } + + public VerifiedAdmin authorize(){ + return VerifiedAdmin.builder() + .admin(this) + .build(); + + } + + public void verify(){ + this.lastTestDate = LocalDate.now(); + this.verified = true; + } + + public boolean verifyLastDate(){ + return LocalDate.now().isBefore(this.lastTestDate.plusDays(1)); + } + +} diff --git a/linkmind/src/main/java/com/app/toaster/admin/entity/VerifiedAdmin.java b/linkmind/src/main/java/com/app/toaster/admin/entity/VerifiedAdmin.java new file mode 100644 index 00000000..0f52d72f --- /dev/null +++ b/linkmind/src/main/java/com/app/toaster/admin/entity/VerifiedAdmin.java @@ -0,0 +1,62 @@ +package com.app.toaster.admin.entity; + +import com.app.toaster.admin.entity.ToasterAdmin; +import com.app.toaster.exception.Error; +import com.app.toaster.exception.model.CustomException; +import jakarta.persistence.*; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.Objects; + +@Getter +@Entity +@NoArgsConstructor +public class VerifiedAdmin { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "verified_admin_id") + private Long id; + + @Column + private String otpSecretKey; // OTP 비밀키 + + @Column + private boolean authorized; + + @OneToOne(optional = false) + @JoinColumn(name="admin_id", unique=true, nullable=false, updatable=false) + private ToasterAdmin admin; + + @Builder + public VerifiedAdmin(final ToasterAdmin admin) { + this.admin = admin; + this.authorized = false; + } + + public void changeOtpSecretKey(String otpSecretKey) { + + if (Objects.isNull(otpSecretKey) || otpSecretKey.isEmpty()) { + throw new CustomException(Error.BAD_REQUEST_VALIDATION, "OTP 비밀키는 필수입력값입니다."); + } + + this.otpSecretKey = otpSecretKey; + } + + public void authorize(){ + this.authorized = true; + } + + // + public void verifiedAdmin(){ + if (!this.authorized){ + throw new CustomException(Error.UNAUTHORIZED_ACCESS, "권한이 없습니다."); + } + this.admin.verify(); + } + + + +} diff --git a/linkmind/src/main/java/com/app/toaster/admin/infrastructure/AdminRepository.java b/linkmind/src/main/java/com/app/toaster/admin/infrastructure/AdminRepository.java new file mode 100644 index 00000000..7fc5fa33 --- /dev/null +++ b/linkmind/src/main/java/com/app/toaster/admin/infrastructure/AdminRepository.java @@ -0,0 +1,10 @@ +package com.app.toaster.admin.infrastructure; + +import com.app.toaster.admin.entity.ToasterAdmin; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface AdminRepository extends JpaRepository { + Optional findByUsername(String username); +} diff --git a/linkmind/src/main/java/com/app/toaster/admin/infrastructure/VerifiedAdminRepository.java b/linkmind/src/main/java/com/app/toaster/admin/infrastructure/VerifiedAdminRepository.java new file mode 100644 index 00000000..85f566e1 --- /dev/null +++ b/linkmind/src/main/java/com/app/toaster/admin/infrastructure/VerifiedAdminRepository.java @@ -0,0 +1,11 @@ +package com.app.toaster.admin.infrastructure; + +import com.app.toaster.admin.entity.VerifiedAdmin; +import com.app.toaster.admin.entity.ToasterAdmin; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface VerifiedAdminRepository extends JpaRepository { + Optional findByAdmin(final ToasterAdmin admin); +} diff --git a/linkmind/src/main/java/com/app/toaster/admin/service/AdminService.java b/linkmind/src/main/java/com/app/toaster/admin/service/AdminService.java new file mode 100644 index 00000000..397d40bc --- /dev/null +++ b/linkmind/src/main/java/com/app/toaster/admin/service/AdminService.java @@ -0,0 +1,130 @@ +package com.app.toaster.admin.service; + +import com.app.toaster.admin.controller.dto.command.VerifyNewAdminCommand; +import com.app.toaster.admin.entity.VerifiedAdmin; +import com.app.toaster.admin.entity.ToasterAdmin; +import com.app.toaster.admin.infrastructure.AdminRepository; +import com.app.toaster.admin.infrastructure.VerifiedAdminRepository; +import com.app.toaster.auth.controller.response.TokenResponseDto; +import com.app.toaster.common.config.jwt.JwtService; +import com.app.toaster.exception.Error; +import com.app.toaster.exception.model.CustomException; +import com.app.toaster.exception.model.NotFoundException; +import com.app.toaster.user.domain.User; +import com.app.toaster.user.infrastructure.UserRepository; +import com.warrenstrange.googleauth.GoogleAuthenticator; +import com.warrenstrange.googleauth.GoogleAuthenticatorKey; +import lombok.RequiredArgsConstructor; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +@Service +@RequiredArgsConstructor +public class AdminService { + + private final UserRepository userRepository; + private final JwtService jwtService; + private final PasswordEncoder passwordEncoder; + private final VerifiedAdminRepository verifiedAdminRepository; + private final AdminRepository adminRepository; + private final GoogleAuthenticator googleAuthenticator; + + @Value(value = "${admin.adminList}") + private String adminList; + + @Value(value = "${admin.salt}") + private String salt; + + @Transactional + public TokenResponseDto issueToken(Long testUserId) { + + User user = userRepository.findById(testUserId) + .orElseThrow(() -> new NotFoundException(Error.NOT_FOUND_USER_EXCEPTION, Error.NOT_FOUND_USER_EXCEPTION.getMessage())); + + // jwt 발급 (액세스 토큰, 리프레쉬 토큰) + String newAccessToken = jwtService.issuedToken(String.valueOf(user.getUserId()), 100000000000L); + String newRefreshToken = jwtService.issuedToken(String.valueOf(user.getUserId()), 10000000000L); + + user.updateRefreshToken(newRefreshToken); + + return TokenResponseDto.of(newAccessToken, newRefreshToken); + } + + + @Transactional + public VerifyNewAdminCommand registerVerifiedUser(final ToasterAdmin toasterAdmin, boolean isNewAdmin) { + + String otpKey = null; + Long id = null; + + if (isNewAdmin) { //새로운 어드민의 경우 등록. + + GoogleAuthenticatorKey key = googleAuthenticator.createCredentials(); + + VerifiedAdmin verifiedAdmin = VerifiedAdmin.builder() + .admin(toasterAdmin) + .build(); + + otpKey = key.getKey(); + verifiedAdmin.changeOtpSecretKey(otpKey); + + id = verifiedAdminRepository.save(verifiedAdmin).getId(); + + } else { //기존 경우의 경우는 그냥 찾기. + + VerifiedAdmin existVerifiedAdmin = verifiedAdminRepository.findByAdmin(toasterAdmin) + .orElseThrow(() -> new CustomException(Error.NOT_FOUND_USER_EXCEPTION, "찾을 수 없는 어드민 증명")); + id = existVerifiedAdmin.getId(); + otpKey = existVerifiedAdmin.getOtpSecretKey(); + + } + + return new VerifyNewAdminCommand(id, otpKey, isNewAdmin); + } + + @Transactional + public VerifyNewAdminCommand registerAdmin(String username, String password) { + + for (String adminString : adminList.split(salt)) { + + if (adminString.equals(username)) { + + ToasterAdmin existAdmin = findExistAdminPreVerification(username, password); + + if (existAdmin != null) { + if (existAdmin.verifyLastDate()) { //검증된 경우면 걍 어드민을 리턴. + return registerVerifiedUser(existAdmin, false); + } + return registerVerifiedUser(existAdmin, true); + } + + + String encPassword = passwordEncoder.encode(password); + + ToasterAdmin toasterAdmin = ToasterAdmin.builder() + .username(username) + .password(encPassword) + .build(); + + return registerVerifiedUser(adminRepository.save(toasterAdmin), true); + } + } + throw new CustomException(Error.NOT_FOUND_USER_EXCEPTION, "어드민이 아닙니다."); + } + + public ToasterAdmin findExistAdminPreVerification(String username, String password) { + Optional admin = adminRepository.findByUsername(username); + + if (passwordEncoder.matches(password, admin.get().getPassword())) { + return admin.get(); + } + + return null; + } + +} diff --git a/linkmind/src/main/java/com/app/toaster/common/dto/ApiResponse.java b/linkmind/src/main/java/com/app/toaster/common/dto/ApiResponse.java index 466c4b93..bccd339f 100644 --- a/linkmind/src/main/java/com/app/toaster/common/dto/ApiResponse.java +++ b/linkmind/src/main/java/com/app/toaster/common/dto/ApiResponse.java @@ -3,14 +3,12 @@ import com.app.toaster.exception.Error; import com.app.toaster.exception.Success; -import lombok.AccessLevel; -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.RequiredArgsConstructor; +import lombok.*; @Getter @RequiredArgsConstructor(access = AccessLevel.PRIVATE) @AllArgsConstructor(access = AccessLevel.PRIVATE) +@NoArgsConstructor(access = AccessLevel.PROTECTED, force = true) public class ApiResponse { private final int code; diff --git a/linkmind/src/main/java/com/app/toaster/exception/Error.java b/linkmind/src/main/java/com/app/toaster/exception/Error.java index 164c3f96..a8aaa750 100644 --- a/linkmind/src/main/java/com/app/toaster/exception/Error.java +++ b/linkmind/src/main/java/com/app/toaster/exception/Error.java @@ -52,6 +52,7 @@ public enum Error { */ UNAUTHORIZED_ACCESS(HttpStatus.FORBIDDEN, "리소스에 대한 권한이 없습니다."), INVALID_USER_ACCESS(HttpStatus.FORBIDDEN, "접근 권한이 없는 유저입니다."), + TIMEOUT_ADMIN_ACCESS(HttpStatus.FORBIDDEN, "타임아웃으로 MFA 인증이 필요합니다."), /** * 422 UNPROCESSABLE_ENTITY @@ -69,6 +70,7 @@ public enum Error { INVALID_ENCRYPT_COMMUNICATION(HttpStatus.INTERNAL_SERVER_ERROR, "ios 통신 증명 과정 중 문제가 발생했습니다."), CREATE_PUBLIC_KEY_EXCEPTION(HttpStatus.INTERNAL_SERVER_ERROR, "publickey 생성 과정 중 문제가 발생했습니다."), FAIL_TO_SEND_PUSH_ALARM(HttpStatus.INTERNAL_SERVER_ERROR, "다수기기 푸시메시지 전송 실패"), + FAIL_TO_QR_GENERATE(HttpStatus.INTERNAL_SERVER_ERROR, "QR 생성 실패"), FAIL_TO_SEND_SQS(HttpStatus.INTERNAL_SERVER_ERROR, "sqs 전송 실패"), INVALID_DISCORD_MESSAGE(HttpStatus.INTERNAL_SERVER_ERROR, "디스코드 알림 전송 실패"), diff --git a/linkmind/src/main/java/com/app/toaster/external/client/discord/DiscordMessage.java b/linkmind/src/main/java/com/app/toaster/external/client/discord/DiscordMessage.java index ba64baf7..6eda9e7b 100644 --- a/linkmind/src/main/java/com/app/toaster/external/client/discord/DiscordMessage.java +++ b/linkmind/src/main/java/com/app/toaster/external/client/discord/DiscordMessage.java @@ -2,6 +2,7 @@ import java.util.List; +import com.fasterxml.jackson.annotation.JsonProperty; import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Builder; @@ -25,5 +26,19 @@ public static class Embed { private String title; private String description; + private EmbedImage image; + } + @Builder + @AllArgsConstructor(access = AccessLevel.PROTECTED) + @NoArgsConstructor(access = AccessLevel.PROTECTED) + @Getter + public static class EmbedImage{ + + private String url; + @JsonProperty(value = "proxy_url") + private String proxyUrl; + + private Integer height; + private Integer width; } } \ No newline at end of file diff --git a/linkmind/src/main/java/com/app/toaster/external/client/discord/DiscordMessageProvider.java b/linkmind/src/main/java/com/app/toaster/external/client/discord/DiscordMessageProvider.java index 90f23200..788de8dc 100644 --- a/linkmind/src/main/java/com/app/toaster/external/client/discord/DiscordMessageProvider.java +++ b/linkmind/src/main/java/com/app/toaster/external/client/discord/DiscordMessageProvider.java @@ -1,5 +1,6 @@ package com.app.toaster.external.client.discord; +import java.io.IOException; import java.io.PrintWriter; import java.io.StringWriter; import java.net.URI; @@ -9,6 +10,7 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.core.env.Environment; +import org.springframework.core.io.ByteArrayResource; import org.springframework.stereotype.Component; import org.springframework.transaction.event.TransactionPhase; import org.springframework.transaction.event.TransactionalEventListener; @@ -20,6 +22,7 @@ import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.web.multipart.MultipartFile; @RequiredArgsConstructor @Component @@ -43,6 +46,19 @@ public void sendNotification(NotificationDto notification) { switch (notification.type()){ case ERROR -> discordClient.sendMessage(URI.create(webhookUrlError), createErrorMessage(notification.e(), notification.request())); case SIGNUP -> discordClient.sendMessage(URI.create(webhookUrlSign), createSignUpMessage()); + case ADMIN -> discordClient.sendMessage(URI.create(webhookUrlError), createAdminMessage(notification.request())); + } + } catch (Exception error) { + log.warn("discord notification fail : " + error); + } + } + } + + public void sendAdmin(NotificationDto notification) { + if (!Arrays.asList(environment.getActiveProfiles()).contains("local")) { // 일단 로컬 막아두겠습니다. TODO: 웹훅 주소 바꾸기 + try { + switch (notification.type()){ + case ADMIN -> discordClient.sendMessage(URI.create(webhookUrlError), createAdminMessage(notification.request())); } } catch (Exception error) { log.warn("discord notification fail : " + error); @@ -95,6 +111,33 @@ private DiscordMessage createErrorMessage(Exception e, String requestUrl) { .build(); } + private DiscordMessage createAdminMessage(String file) throws IOException { + return DiscordMessage.builder() + .content("# 😍스웨거 MFA 만들어보기") + .embeds( + List.of( + DiscordMessage.Embed.builder() + .title("ℹ️ 에러 정보") + .description( + "### 🕖 발생 시간\n" + + LocalDateTime.now() + + "\n" + + "### 🔗 요청 URL\n" + + "스웨거 테스트" + + "\n" + + "### 📄 Stack Trace\n" + + "\n```") + .image(DiscordMessage.EmbedImage.builder() + .url(file) + .height(300) + .width(300) + .build() + ).build() + ) + ) + .build(); + } + private String createRequestFullPath(WebRequest webRequest) { HttpServletRequest request = ((ServletWebRequest) webRequest).getRequest(); String fullPath = request.getMethod() + " " + request.getRequestURL(); diff --git a/linkmind/src/main/java/com/app/toaster/external/client/discord/NotificationType.java b/linkmind/src/main/java/com/app/toaster/external/client/discord/NotificationType.java index ac154ab0..c1b9801e 100644 --- a/linkmind/src/main/java/com/app/toaster/external/client/discord/NotificationType.java +++ b/linkmind/src/main/java/com/app/toaster/external/client/discord/NotificationType.java @@ -2,5 +2,6 @@ public enum NotificationType { ERROR, - SIGNUP + SIGNUP, + ADMIN }