Skip to content

Commit 3ae6447

Browse files
authored
Merge pull request #257 from Link-MIND/feat/#256
[Feat/#256] 백오피스 관련 pr입니다.
2 parents 3b6d6a1 + 158e146 commit 3ae6447

20 files changed

+650
-5
lines changed

linkmind/build.gradle

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,16 @@ dependencies {
8181

8282
// openfeign
8383
implementation 'org.springframework.cloud:spring-cloud-starter-openfeign'
84+
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.0.2'
85+
implementation 'org.springframework:spring-test:6.1.1'
86+
87+
implementation 'com.warrenstrange:googleauth:1.4.0'
88+
implementation 'com.google.zxing:core:3.3.3'
89+
implementation 'com.google.zxing:javase:3.3.3'
90+
91+
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
92+
93+
8494

8595
}
8696

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package com.app.toaster.admin.common;
2+
3+
import com.app.toaster.common.dto.ApiResponse;
4+
import com.app.toaster.exception.Success;
5+
import lombok.AccessLevel;
6+
import lombok.AllArgsConstructor;
7+
import lombok.Getter;
8+
import lombok.RequiredArgsConstructor;
9+
10+
@Getter
11+
@RequiredArgsConstructor(access = AccessLevel.PRIVATE)
12+
@AllArgsConstructor(access = AccessLevel.PRIVATE)
13+
public class RedirectResponse<T> extends ApiResponse<T> {
14+
private final int code;
15+
private final String redirectUrl;
16+
private T data;
17+
18+
19+
public static <T> RedirectResponse<T> success(Success success, String redirectUrl, T data){
20+
return new RedirectResponse<>(success.getHttpStatusCode(), redirectUrl, data);
21+
}
22+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package com.app.toaster.admin.config;
2+
3+
import com.warrenstrange.googleauth.GoogleAuthenticator;
4+
import org.springframework.context.annotation.Bean;
5+
import org.springframework.context.annotation.Configuration;
6+
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
7+
import org.springframework.security.crypto.password.PasswordEncoder;
8+
9+
@Configuration
10+
public class AdminPasswordEncoder {
11+
12+
@Bean
13+
public PasswordEncoder passwordEncoder(){
14+
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
15+
}
16+
17+
@Bean
18+
public GoogleAuthenticator googleAuthenticator(){
19+
return new GoogleAuthenticator();
20+
}
21+
22+
23+
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
package com.app.toaster.admin.config;
2+
3+
import com.app.toaster.admin.entity.VerifiedAdmin;
4+
import com.app.toaster.admin.entity.ToasterAdmin;
5+
import com.app.toaster.admin.infrastructure.VerifiedAdminRepository;
6+
import com.app.toaster.exception.Error;
7+
import com.app.toaster.exception.model.CustomException;
8+
import com.google.zxing.BarcodeFormat;
9+
import com.google.zxing.WriterException;
10+
import com.google.zxing.client.j2se.MatrixToImageWriter;
11+
import com.google.zxing.common.BitMatrix;
12+
import com.google.zxing.qrcode.QRCodeWriter;
13+
import com.warrenstrange.googleauth.GoogleAuthenticator;
14+
import lombok.extern.slf4j.Slf4j;
15+
import org.springframework.beans.factory.annotation.Value;
16+
import org.springframework.stereotype.Component;
17+
import org.springframework.web.multipart.MultipartFile;
18+
import org.springframework.mock.web.MockMultipartFile;
19+
20+
import javax.imageio.ImageIO;
21+
import java.awt.image.BufferedImage;
22+
import java.io.ByteArrayOutputStream;
23+
import java.io.IOException;
24+
25+
@Component
26+
@Slf4j
27+
public class QrMfaAuthenticator {
28+
29+
private String secret;
30+
private final GoogleAuthenticator googleAuthenticator = new GoogleAuthenticator();
31+
private final VerifiedAdminRepository verifiedAdminRepository;
32+
33+
34+
public QrMfaAuthenticator(@Value("${admin.secret}") final String secret, final VerifiedAdminRepository verifiedAdminRepository) {
35+
this.secret = secret;
36+
this.verifiedAdminRepository = verifiedAdminRepository;
37+
}
38+
39+
40+
public MultipartFile generateQrCode(String userKey) {
41+
String data = makeQrDataString(userKey);
42+
QRCodeWriter qrCodeWriter = new QRCodeWriter();
43+
try {
44+
BitMatrix bitMatrix = qrCodeWriter.encode(data, BarcodeFormat.QR_CODE, 240, 240);
45+
BufferedImage image = MatrixToImageWriter.toBufferedImage(bitMatrix);
46+
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
47+
48+
ImageIO.write(image, "png", byteArrayOutputStream);
49+
byteArrayOutputStream.close();
50+
51+
byte[] qrCodeBytes = byteArrayOutputStream.toByteArray();
52+
53+
return new MockMultipartFile("qrCode", "qrcode.png", "image/png", qrCodeBytes);
54+
} catch (WriterException | IOException e) {
55+
log.error(e.getMessage());
56+
}
57+
return null;
58+
}
59+
60+
private String makeQrDataString(String userKey) {
61+
return "otpauth://totp/toaster?secret=" + userKey + "&issuer=Google";
62+
}
63+
64+
public ToasterAdmin verifyGoogleTotpCode(Integer verificationCode, Long id) {
65+
66+
VerifiedAdmin admin = verifiedAdminRepository.findById(id).orElseThrow(
67+
() -> new CustomException(Error.NOT_FOUND_USER_EXCEPTION, "어드민이 존재하지않는다.")
68+
);
69+
System.out.println(admin.getAdmin().getUsername());
70+
71+
if (verificationCode != null) {
72+
try {
73+
if (!googleAuthenticator.authorize(admin.getOtpSecretKey(), verificationCode)) {
74+
throw new CustomException(Error.BAD_REQUEST_VALIDATION, "유효하지 않은 인증코드입니다.");
75+
}
76+
admin.authorize();
77+
admin.verifiedAdmin();
78+
return admin.getAdmin();
79+
} catch (Exception e) {
80+
log.error("인증 쪽에서 에러 발생.");
81+
}
82+
} else {
83+
throw new CustomException(Error.TOKEN_TIME_EXPIRED_EXCEPTION, "만료된 코드 입니다.");
84+
}
85+
return null;
86+
}
87+
88+
89+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package com.app.toaster.admin.config;
2+
3+
import io.swagger.v3.oas.annotations.OpenAPIDefinition;
4+
import io.swagger.v3.oas.annotations.info.Info;
5+
import io.swagger.v3.oas.models.Components;
6+
import io.swagger.v3.oas.models.OpenAPI;
7+
import io.swagger.v3.oas.models.security.SecurityRequirement;
8+
import io.swagger.v3.oas.models.security.SecurityScheme;
9+
import org.springframework.context.annotation.Bean;
10+
import org.springframework.context.annotation.Configuration;
11+
12+
13+
@OpenAPIDefinition(
14+
info = @Info(
15+
title = "토스터 API 명세서입니다.",
16+
description = "어드민 자격이 필요한 것들은 스웨거 문서를 올리지 않겠습니다.",
17+
version = "v1"
18+
)
19+
)
20+
@Configuration
21+
public class SwaggerConfig {
22+
23+
private static final String BEARER_TOKEN_PREFIX = "Bearer";
24+
25+
@Bean
26+
public OpenAPI openAPI() {
27+
String securityJwtName = "JWT";
28+
SecurityRequirement securityRequirement = new SecurityRequirement().addList(securityJwtName);
29+
Components components = new Components()
30+
.addSecuritySchemes(securityJwtName, new SecurityScheme()
31+
.name(securityJwtName)
32+
.type(SecurityScheme.Type.HTTP)
33+
.scheme(BEARER_TOKEN_PREFIX)
34+
.bearerFormat(securityJwtName));
35+
36+
return new OpenAPI()
37+
.addSecurityItem(securityRequirement)
38+
.components(components);
39+
}
40+
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
package com.app.toaster.admin.controller;
2+
3+
import com.app.toaster.admin.common.RedirectResponse;
4+
import com.app.toaster.admin.controller.dto.command.VerifyNewAdminCommand;
5+
import com.app.toaster.admin.controller.dto.request.AdminTotpDto;
6+
import com.app.toaster.admin.controller.dto.request.SignInDto;
7+
import com.app.toaster.admin.controller.dto.response.AdminResponse;
8+
import com.app.toaster.admin.entity.ToasterAdmin;
9+
import com.app.toaster.admin.service.AdminService;
10+
import com.app.toaster.admin.config.QrMfaAuthenticator;
11+
import com.app.toaster.exception.Error;
12+
import com.app.toaster.exception.Success;
13+
import com.app.toaster.exception.model.CustomException;
14+
import com.app.toaster.external.client.aws.S3Service;
15+
import com.app.toaster.external.client.discord.DiscordMessageProvider;
16+
17+
import com.app.toaster.external.client.discord.NotificationDto;
18+
import com.app.toaster.external.client.discord.NotificationType;
19+
20+
import jakarta.servlet.http.HttpServletResponse;
21+
import jakarta.servlet.http.HttpSession;
22+
import lombok.RequiredArgsConstructor;
23+
import org.springframework.stereotype.Controller;
24+
import org.springframework.ui.Model;
25+
import org.springframework.web.bind.annotation.*;
26+
import org.springframework.web.multipart.MultipartFile;
27+
28+
import java.io.IOException;
29+
30+
@Controller
31+
@RequestMapping("/admin")
32+
@RequiredArgsConstructor
33+
class AdminController {
34+
35+
private final DiscordMessageProvider discordMessageProvider;
36+
private final S3Service s3Service;
37+
private final QrMfaAuthenticator qrMfaAuthenticator;
38+
private final AdminService adminService;
39+
40+
@PostMapping("/register")
41+
@ResponseBody
42+
public RedirectResponse<?> registerAdmin(@RequestBody SignInDto signInDto, HttpSession session) {
43+
44+
VerifyNewAdminCommand res = adminService.registerAdmin(signInDto.username(), signInDto.password());
45+
46+
String key = res.key();
47+
Long adminId = res.id();
48+
boolean isNewAdmin = res.isNewAdmin();
49+
50+
if (isNewAdmin){
51+
key = executeDiscordQrOperation(key);
52+
}
53+
54+
session.setAttribute("VerifyId", adminId);
55+
session.setAttribute("QrUrl", key);
56+
57+
return RedirectResponse.success(Success.LOGIN_SUCCESS, "verify",null);
58+
}
59+
60+
@GetMapping("/register")
61+
public String getRegisterAdmin(Model model, HttpServletResponse response) throws IOException {
62+
return "basic/register";
63+
}
64+
65+
@GetMapping("/verify")
66+
public String responseIsAdminCodeView(Model model) {
67+
return "basic/qrForm";
68+
}
69+
70+
@GetMapping("/main")
71+
public String adminMain(Model model) {
72+
// model.addAttribute("imageUrl", imageUrl); // imageUrl을 모델에 추가
73+
return "basic/admin";
74+
}
75+
76+
@PostMapping("/verify-code")
77+
@ResponseBody
78+
public RedirectResponse<AdminResponse> responseIsAdminView(HttpSession session, @RequestBody AdminTotpDto request) throws IOException {
79+
//admin인지 판단
80+
Long verifyId = (Long) session.getAttribute("VerifyId");
81+
82+
if (verifyId == null) {
83+
throw new CustomException(Error.BAD_REQUEST_ID, "세션에 VerifyId가 없습니다.");
84+
}
85+
86+
ToasterAdmin toasterAdmin = qrMfaAuthenticator.verifyGoogleTotpCode(Integer.valueOf(request.code()), verifyId);
87+
88+
if (toasterAdmin == null){
89+
throw new CustomException(Error.BAD_REQUEST_ID, "잘못된 유저 입니다.");
90+
}
91+
AdminResponse result = new AdminResponse(toasterAdmin.getUsername());
92+
s3Service.deleteImage((String) session.getAttribute("QrUrl"));
93+
return RedirectResponse.success(Success.LOGIN_SUCCESS,"main", result);
94+
}
95+
96+
private String executeDiscordQrOperation(String key){
97+
MultipartFile qrImage = qrMfaAuthenticator.generateQrCode(key);
98+
String imageKey = s3Service.uploadImage(qrImage, "admin/");
99+
String qrUrl = s3Service.getURL(imageKey);
100+
discordMessageProvider.sendAdmin(new NotificationDto(NotificationType.ADMIN,null, qrUrl));
101+
return qrUrl;
102+
}
103+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
package com.app.toaster.admin.controller.dto.command;
2+
3+
public record VerifyNewAdminCommand(
4+
Long id,
5+
String key,
6+
boolean isNewAdmin
7+
) {
8+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
package com.app.toaster.admin.controller.dto.request;
2+
3+
public record AdminTotpDto(
4+
String code
5+
) {
6+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package com.app.toaster.admin.controller.dto.request;
2+
3+
public record SignInDto(
4+
String username,
5+
String password
6+
) {
7+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
package com.app.toaster.admin.controller.dto.response;
2+
3+
public record AdminResponse(String userName)
4+
{
5+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
package com.app.toaster.admin.entity;
2+
3+
import jakarta.persistence.*;
4+
import lombok.AccessLevel;
5+
import lombok.Builder;
6+
import lombok.Getter;
7+
import lombok.NoArgsConstructor;
8+
9+
import java.time.LocalDate;
10+
11+
@Entity
12+
@Getter
13+
@NoArgsConstructor(access = AccessLevel.PROTECTED)
14+
public class ToasterAdmin {
15+
16+
@Id
17+
@GeneratedValue(strategy = GenerationType.IDENTITY)
18+
@Column(name = "admin_id")
19+
private Long id;
20+
21+
@Column(name = "username")
22+
private String username;
23+
24+
@Column(name = "password")
25+
private String password;
26+
27+
@Column(name = "verified")
28+
private boolean verified;
29+
30+
@Column(name = "masterToken")
31+
private String masterToken;
32+
33+
@Column(name = "lastVerifiedDate")
34+
private LocalDate lastTestDate;
35+
36+
@Builder
37+
public ToasterAdmin(String username, String password){
38+
this.username = username;
39+
this.password = password;
40+
this.verified = false;
41+
this.lastTestDate = LocalDate.now();
42+
}
43+
44+
public VerifiedAdmin authorize(){
45+
return VerifiedAdmin.builder()
46+
.admin(this)
47+
.build();
48+
49+
}
50+
51+
public void verify(){
52+
this.lastTestDate = LocalDate.now();
53+
this.verified = true;
54+
}
55+
56+
public boolean verifyLastDate(){
57+
return LocalDate.now().isBefore(this.lastTestDate.plusDays(1));
58+
}
59+
60+
}

0 commit comments

Comments
 (0)