Skip to content

Commit

Permalink
Merge pull request #257 from Link-MIND/feat/#256
Browse files Browse the repository at this point in the history
[Feat/#256] 백오피스 관련 pr입니다.
  • Loading branch information
sss4920 authored Feb 3, 2025
2 parents 3b6d6a1 + 158e146 commit 3ae6447
Show file tree
Hide file tree
Showing 20 changed files with 650 additions and 5 deletions.
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

0 comments on commit 3ae6447

Please sign in to comment.