Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Feat/#256] 백오피스 관련 pr입니다. #257

Merged
merged 15 commits into from
Feb 3, 2025
Merged
10 changes: 10 additions & 0 deletions linkmind/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'



}

Expand Down
Original file line number Diff line number Diff line change
@@ -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<T> extends ApiResponse<T> {
private final int code;
private final String redirectUrl;
private T data;


public static <T> RedirectResponse<T> success(Success success, String redirectUrl, T data){
return new RedirectResponse<>(success.getHttpStatusCode(), redirectUrl, data);
}
}
Original file line number Diff line number Diff line change
@@ -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();
}


}
Original file line number Diff line number Diff line change
@@ -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;
}


}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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<AdminResponse> 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;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.app.toaster.admin.controller.dto.command;

public record VerifyNewAdminCommand(
Long id,
String key,
boolean isNewAdmin
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.app.toaster.admin.controller.dto.request;

public record AdminTotpDto(
String code
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.app.toaster.admin.controller.dto.request;

public record SignInDto(
String username,
String password
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.app.toaster.admin.controller.dto.response;

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

}
Loading