@Data
@AllArgsConstructor
@NoArgsConstructor
public class ReadDto {
public static final int MODEL_LENGTH = 35;
private Integer dcv;
private Integer dca;
private Integer dcw;
}
여기서 만약, 필드 별 필요한 데이터의 타입 등이 있을 경우 아래와 같이 어노테이션을 만들어서 Dto 필드에 넣어준다.
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
public @interface ModbusValue {
int offset();
ModbusValueType type() default UINT_16;
}
바뀐 Dto
@Data
@AllArgsConstructor
@NoArgsConstructor
public class ReadDto {
public static final int MODEL_LENGTH = 35;
@ModbusValue(offset = 0, type = INT_16)
private Integer dcv;
@ModbusValue(offset = 1, type = INT_16)
private Integer dca;
@ModbusValue(offset = 2, type = INT_16)
private Integer dcw;
}
특정한 Dto가 아닌, 제네릭으로 받아서 Dto마다의 결과값을 반환하기 위해 제네릭 메소드로 생성한다.
public <T> T getDataObject(T object) {
try {
// Dto의 static 필드값이 필요한 경우
int quantity = object.getClass().getField(MODEL_LENGTH)
.getInt(object);
Class<?> modbusEntity = object.getClass();
Field[] columns = modbusEntity.getDeclaredFields();
// 필드 별 로직 구성
Arrays.stream(columns)
.parallel()
.forEach(column -> {
try {
ModbusValue annotation = column.getAnnotation(ModbusValue.class);
if (annotation == null) {
return;
}
int offset = annotation.offset();
var type = annotation.type();
Object value = getModbusValue(type, offset);
column.setAccessible(true);
column.set(object, castType(value, column.getType().getSimpleName()));
} catch (IllegalAccessException e) {
throw new RuntimeException(e);
}
});
return object; // 최종 DTO 객체 반환
} catch (Exception e) {
log.error("[ getDtoWithModbusRawData Error ] message: {}", e.getMessage(), e);
throw new RuntimeException(e);
}
}
이렇게 하면, 원하는 Dto의 필드들에 값을 주입하고 다시 Dto를 반환하게 할 수 있다.
emailCertNumberMap: 이메일 검증 시에 쓰이며, Dto에는 code와 createdAt이 있다. 검증 로직에서 아래 상수로 시간이 지나면 검증이 실패하게끔 만들어준다. 이 포스트에서는 인메모리로 다루어봤다.
VERIFY_TIMEOUT: 서버에서의 이메일 검증 만료 시간
@Service
@RequiredArgsConstructor
@Transactional
@Slf4j
public class UserService {
public static final int VERIFY_TIMEOUT = 35;
private final UserRepository repository;
private final EmailService emailService;
private final UserGroupRepository userGroupRepository;
private final PasswordEncoder passwordEncoder;
@Getter
public Map<String, MfaEmailVerifyMapDto> emailCertNumberMap = new HashMap<>();
...
public void registerMfaTypes(MfaRegisterDto mfaRegisterDto) {
User user = repository.findByUsernameAndDeletedAtIsNull(mfaRegisterDto.getId())
.orElseThrow();
List<MfaTypesDto> mfaTypes = user.getMfaTypes();
List<MfaType> mfaTypeList = mfaTypes
.stream()
.map(MfaTypesDto::getMfaType)
.toList();
if (mfaTypeList.contains(mfaRegisterDto.getMfaType())) {
throw new IllegalArgumentException("이미 등록된 인증 입니다.");
}
mfaTypes.add(new MfaTypesDto(mfaRegisterDto.getMfaType(), LocalDateTime.now()));
user.updateIsUsingMfa(true);
user.updateMfaTypes(mfaTypes);
}
public void deleteMfaTypes(MfaDeleteDto deleteDto) {
User user = repository.findByUsernameAndDeletedAtIsNull(deleteDto.getId())
.orElseThrow();
List<MfaTypesDto> mfaTypes = user.getMfaTypes();
List<MfaType> mfaTypeList = mfaTypes
.stream()
.map(MfaTypesDto::getMfaType)
.toList();
if (!mfaTypeList.contains(deleteDto.getMfaType())) {
throw new IllegalArgumentException("등록되지 않은 인증 입니다.");
}
mfaTypes.removeIf(dto -> dto.getMfaType() == deleteDto.getMfaType());
if (mfaTypes.size() == 0) {
user.updateIsUsingMfa(false);
}
user.updateMfaTypes(mfaTypes);
}
public String registerAuthenticator(MfaOtpRegisterDto dto) {
return repository
.findByUsernameAndDeletedAtIsNull(dto.getId())
.orElseThrow()
.getSecret();
}
public void verifyAuthenticatorCode(MfaOtpVerifyDto dto) {
String name = dto.getEmail();
User user = repository.findByUsernameAndDeletedAtIsNull(name).orElseThrow();
Totp totp = new Totp(user.getSecret());
if (!isValidLong(dto.getCode()) || !totp.verify(dto.getCode())) {
throw new AuthorizationException("Code Invalid");
}
}
private boolean isValidLong(String code) {
try {
Long.parseLong(code);
} catch (NumberFormatException e) {
return false;
}
return true;
}
public void sendAuthenticationCodeByEmail(String userId) {
repository.findByUsernameAndDeletedAtIsNull(userId).orElseThrow();
Random num = new Random();
StringBuilder randomNumber = new StringBuilder();
for (int i = 0; i < 6; i++) {
randomNumber.append(num.nextInt(10));
}
EmailSendDto emailSendDto = new EmailSendDto(
AUTH,
"이메일 서버",
포트,
"이메일 주소",
userId,
"",
"",
true,
TLS_V_1_2,
"인증번호",
"인증번호는 " + randomNumber + " 입니다.",
null
);
emailService.sendMail(emailSendDto);
this.emailCertNumberMap.put(
userId,
new MfaEmailVerifyMapDto(
randomNumber.toString(),
LocalDateTime.now()
)
);
}
public void certEmail(MfaEmailVerifyRequestDto mfaEmailVerifyRequestDto) {
String name = mfaEmailVerifyRequestDto.getEmail();
MfaEmailVerifyMapDto mapDto = this.emailCertNumberMap.get(name);
Duration between = Duration.between(LocalDateTime.now(), mapDto.getCreatedAt());
if (Math.abs(between.getSeconds()) > VERIFY_TIMEOUT) {
throw new AuthorizationException("Code Expired");
}
if (!Objects.equals(mapDto.getCode(), mfaEmailVerifyRequestDto.getCode())) {
throw new AuthorizationException("Code Invalid");
}
}
}
11. AuthService
@Service
@RequiredArgsConstructor
@Transactional
@Slf4j
public class AuthService {
private final UserRepository userRepository;
private final AuthenticationManager authenticationManager;
private final PasswordEncoder passwordEncoder;
@Getter
public Map<String, MfaEmailVerifyMapDto> emailCertNumberMap = new HashMap<>();
public ResponseLoginDto login(RequestLoginDto loginDto) {
User user = userRepository.findByUsernameAndDeletedAtIsNull(loginDto.getId()).orElseThrow();
if (!passwordEncoder.matches(loginDto.getPassword(), user.getPassword())) {
log.error("[Password does not match] Username: {}", user.getUsername());
throw new TokenProblemException("Password does not match");
}
if (user.getIsBlocked()) {
log.error("[User login is blocked] Username: {}", user.getUsername());
throw new AccessDeniedException("Blocked user.");
}
Authentication authentication = this.authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(loginDto.getId(), loginDto.getPassword()));
SecurityContextHolder.getContext().setAuthentication(authentication);
UserDetailsImpl userDetails = (UserDetailsImpl) authentication.getPrincipal();
List<String> roles = userDetails.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.toList();
List<String> groupNames = new ArrayList<>();
for (UserGroup group : user.getGroups()) {
groupNames.add(group.getName());
}
return new ResponseLoginDto(
user.getIsUsingMfa(),
user.getMfaTypes(),
user.getId(),
user.getUsername(),
user.getFirstName(),
user.getLastName(),
groupNames,
roles,
null
);
}
@Retry(value = 2)
public ResponseLoginDto refreshToken(String username) {
User user = userRepository.findByUsernameAndDeletedAtIsNull(username).orElseThrow();
Set<String> roles = new HashSet<>();
List<String> groupNames = new ArrayList<>();
for (UserGroup group : user.getGroups()) {
roles.addAll(List.of(group.getRolesList()));
groupNames.add(group.getName());
}
return new ResponseLoginDto(
user.getIsUsingMfa(),
user.getMfaTypes(),
user.getId(),
user.getUsername(),
user.getFirstName(),
user.getLastName(),
groupNames,
roles.stream().toList(),
null);
}
public long getExpiredTime(Cookie jwtCookie) {
return Timestamp.valueOf(LocalDateTime.now().plusSeconds(jwtCookie.getMaxAge()))
.getTime();
}
}
최종적으로 API를 만들기 위한 단계이다. API는 로그인 시 필요한 AuthController, 사용자 별 복합 인증 관리에 필요한 UserController 두 곳에서 사용할 것이다.
12. AuthController
@Slf4j
@RequiredArgsConstructor
@RestController
@RequestMapping("/auth")
public class AuthController {
private static final int CLIENT_VERIFY_TIMEOUT = 30;
private final AuthService service;
private final UserService userService;
private final JwtUtil jwtUtil;
@PostMapping("/login")
@EventLogging(code = SecurityEventCode.USER_LOGIN)
public CommonResponse<ResponseLoginDto> login(
HttpServletResponse response,
HttpSession session,
@Valid @RequestBody RequestLoginDto loginDto
) {
ResponseLoginDto responseLoginDto = service.login(loginDto);
if (!responseLoginDto.isUsingMfa()) {
long exp = addJwtCookie(response, loginDto.getId());
responseLoginDto.setExp(exp);
return new CommonResponse<>(responseLoginDto);
}
return new CommonResponse<>(responseLoginDto);
}
@PostMapping("/mfa/authenticator/verify")
public CommonResponse<Long> verifyAuthenticatorCode(
HttpServletResponse response,
HttpSession session,
@Valid @RequestBody MfaOtpVerifyDto dto
) {
verifySessionMfaType(session, OTP);
userService.verifyAuthenticatorCode(dto);
long exp = addJwtCookie(
response,
dto.getEmail()
);
return new CommonResponse<>(exp);
}
@PostMapping("/mfa/email/send")
public CommonResponse<Integer> sendAuthenticationCodeByEmail(
@RequestBody MfaEmailSendRequestDto mfaEmailSendRequestDto
) {
userService.sendAuthenticationCodeByEmail(mfaEmailSendRequestDto.getEmail());
return new CommonResponse<>(CLIENT_VERIFY_TIMEOUT);
}
@PostMapping("/mfa/email/verify")
public CommonResponse<Long> certEmail(
HttpServletResponse response,
HttpSession session,
@RequestBody MfaEmailVerifyRequestDto dto
) {
verifySessionMfaType(session, EMAIL);
userService.certEmail(dto);
long exp = addJwtCookie(
response,
dto.getEmail()
);
return new CommonResponse<>(exp);
}
@PostMapping("/logout")
@EventLogging(code = SecurityEventCode.USER_LOGOUT)
public CommonResponse<Boolean> logout(
HttpServletResponse response,
HttpSession session
) {
ResponseCookie jwtCookie = jwtUtil.getCleanJwtCookie();
response.addHeader(HttpHeaders.SET_COOKIE, jwtCookie.toString());
return new CommonResponse<>(true);
}
@PostMapping("/refresh")
public CommonResponse<ResponseLoginDto> refresh(
HttpServletResponse response,
HttpServletRequest request
) {
String token = this.jwtUtil.getJwtFromCookies(request);
if ((token == null) || (token.length() < 1)) {
log.error("[Invalid token] token: {}", token);
throw new TokenProblemException("Invalid token");
}
if (!jwtUtil.validateJwtToken(token)) {
log.error("[The token has expired] token: {}", token);
throw new TokenProblemException("The token has expired.");
}
String username = jwtUtil.getUsernameFromJwtToken(token);
Cookie jwtCookie = jwtUtil.generateJwtCookie(username);
response.addCookie(jwtCookie);
var exp = service.getExpiredTime(jwtCookie);
ResponseLoginDto responseLoginDto = service.refreshToken(username);
responseLoginDto.setExp(exp);
return new CommonResponse<>(responseLoginDto);
}
private void verifySessionMfaType(HttpSession session, MfaType mfaType) {
MfaType isUsingMfa = sessionUtil.getIsUsingMfa(session);
if (isUsingMfa != null && isUsingMfa != mfaType) {
throw new IllegalArgumentException("이미 다른 복합 인증이 사용중입니다.");
}
sessionUtil.setIsUsingMfa(session, mfaType);
}
private long addJwtCookie(HttpServletResponse response, String name) {
Cookie jwtCookie = jwtUtil.generateJwtCookie(name);
response.addCookie(jwtCookie);
return service.getExpiredTime(jwtCookie);
}
}
13. UserController
@Slf4j
@RequiredArgsConstructor
@RestController
@RequestMapping("/user")
@PreAuthorize("hasRole('ROLE_USER_READ') || hasRole('ROLE_USER_ADMIN')")
public class UserController {
private final UserService service;
@GetMapping
public CommonResponse<List<ResponseUserDto>> findAll() {
return new CommonResponse<>(service.findAll());
}
@GetMapping("/{id}")
public CommonResponse<ResponseUserDto> findByIndexId(
@PathVariable(value = "id") long userId) {
return new CommonResponse<>(service.findByIndexId(userId));
}
@GetMapping("/name/{username}")
public CommonResponse<Boolean> findByUsername(
@PathVariable(value = "username") String username) {
return new CommonResponse<>(service.isUserEmptyByUsername(username));
}
@PreAuthorize("hasRole('ROLE_USER_ADMIN')")
@PostMapping
@EventLogging(code = SecurityEventCode.USER_USER_CREATE)
public CommonResponse<Long> create(
@RequestBody @Valid UserCreateDto createDto) {
Long id = service.create(createDto);
createDto.setPassword("");
return new CommonResponse<>(id);
}
@PreAuthorize("hasRole('ROLE_USER_ADMIN')")
@PutMapping
@EventLogging(code = SecurityEventCode.USER_USER_UPDATE)
public CommonResponse<Boolean> updateUser(
@RequestBody @Valid UserUpdateDto updateDto) {
service.updateUser(updateDto);
return new CommonResponse<>(true);
}
@PreAuthorize("hasRole('ROLE_USER_ADMIN')")
@PutMapping("/password")
@EventLogging(code = SecurityEventCode.USER_USER_PASSWORD_CHANGE)
public CommonResponse<Boolean> updatePasswordByUser(
@RequestBody @Valid UserUpdatePasswordDto updateDto) {
service.updatePasswordByUser(updateDto);
return new CommonResponse<>(true);
}
@PreAuthorize("hasRole('ROLE_USER_ADMIN')")
@PutMapping("/me")
@EventLogging(code = SecurityEventCode.USER_USER_UPDATE_OWN_USER)
public CommonResponse<Boolean> updateOwnUser(
@RequestBody @Valid OwnUserUpdateDto updateDto) {
service.updateOwnUser(updateDto);
return new CommonResponse<>(true);
}
@PostMapping("/mfa/register")
public CommonResponse<Boolean> registerMfaTypes(
@Valid @RequestBody MfaRegisterDto registerDto
) {
service.registerMfaTypes(registerDto);
return new CommonResponse<>(true);
}
@PostMapping("/mfa/authenticator/verify")
public CommonResponse<Boolean> verifyAuthenticatorCode(
@Valid @RequestBody MfaOtpVerifyDto dto
) {
service.verifyAuthenticatorCode(dto);
return new CommonResponse<>(true);
}
@PostMapping("/mfa/delete")
public CommonResponse<Boolean> deleteMfaTypes(
@Valid @RequestBody MfaDeleteDto deleteDto
) {
service.deleteMfaTypes(deleteDto);
return new CommonResponse<>(true);
}
@PostMapping("/mfa/authenticator/register")
public CommonResponse<String> registerAuthenticator(
@Valid @RequestBody MfaOtpRegisterDto otpRegisterDto
) {
return new CommonResponse<>(service.registerAuthenticator(otpRegisterDto));
}
@PostMapping("/mfa/email/send")
public CommonResponse<Boolean> sendAuthenticationCodeByEmail(
@Valid @RequestBody MfaEmailSendRequestDto mfaEmailSendRequestDto
) {
service.sendAuthenticationCodeByEmail(mfaEmailSendRequestDto.getEmail());
return new CommonResponse<>(true);
}
@PostMapping("/mfa/email/verify")
public CommonResponse<Boolean> certEmail(
@Valid @RequestBody MfaEmailVerifyRequestDto dto
) {
service.certEmail(dto);
return new CommonResponse<>(true);
}
@PreAuthorize("hasRole('ROLE_USER_ADMIN')")
@PutMapping("/admin")
@EventLogging(code = SecurityEventCode.USER_USER_PASSWORD_CHANGE_BY_ADMIN)
public CommonResponse<Boolean> updatePasswordByAdmin(
@RequestBody @Valid UserUpdatePasswordDto updateDto) {
service.updatePasswordByAdmin(updateDto);
return new CommonResponse<>(true);
}
@PreAuthorize("hasRole('ROLE_USER_ADMIN')")
@DeleteMapping
@EventLogging(code = SecurityEventCode.USER_USER_DELETE)
public CommonResponse<Boolean> deleteUsers(
@Valid @RequestBody CommonIdListDto listDto) {
service.deleteUsers(listDto);
return new CommonResponse<>(true);
}
}
@ToString
@Getter
@NoArgsConstructor
@Builder
public class ApiResponseDto<T> {
private T data;
private ApiResponseDto(T data){
this.data=data;
}
public static <T> ApiResponseDto<T> of(T data) {
return new ApiResponseDto<>(data);
}
}
utils/consts/EnumDocs
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class EnumDocs {
// 문서화하고 싶은 모든 enum값을 명시
Map<String,String> Sex;
Map<String,String> memberStatus;
}
utils/consts/CommonDocController
@ToString
@Getter
@NoArgsConstructor
@Builder
public class ApiResponseDto<T> {
private T data;
private ApiResponseDto(T data){
this.data=data;
}
public static <T> ApiResponseDto<T> of(T data) {
return new ApiResponseDto<>(data);
}
}
utils/consts/CommonDocControllerTest
// restdocs의 get 이 아님을 주의!!
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
class CommonDocControllerTest extends RestDocsTestSupport {
@Test
public void enums() throws Exception {
// 요청
ResultActions result = this.mockMvc.perform(
get("/test/enums")
.contentType(MediaType.APPLICATION_JSON)
);
// 결과값
MvcResult mvcResult = result.andReturn();
// 데이터 파싱
EnumDocs enumDocs = getData(mvcResult);
// 문서화 진행
result.andExpect(status().isOk())
.andDo(restDocs.document(
customResponseFields("custom-response", beneathPath("data.memberStatus").withSubsectionId("memberStatus"), // (1)
attributes(key("title").value("memberStatus")),
enumConvertFieldDescriptor((enumDocs.getMemberStatus()))
),
customResponseFields("custom-response", beneathPath("data.sex").withSubsectionId("sex"),
attributes(key("title").value("sex")),
enumConvertFieldDescriptor((enumDocs.getSex()))
)
));
}
// 커스텀 템플릿 사용을 위한 함수
public static CustomResponseFieldsSnippet customResponseFields
(String type,
PayloadSubsectionExtractor<?> subsectionExtractor,
Map<String, Object> attributes, FieldDescriptor... descriptors) {
return new CustomResponseFieldsSnippet(type, subsectionExtractor, Arrays.asList(descriptors), attributes
, true);
}
// Map으로 넘어온 enumValue를 fieldWithPath로 변경하여 리턴
private static FieldDescriptor[] enumConvertFieldDescriptor(Map<String, String> enumValues) {
return enumValues.entrySet().stream()
.map(x -> fieldWithPath(x.getKey()).description(x.getValue()))
.toArray(FieldDescriptor[]::new);
}
// mvc result 데이터 파싱
private EnumDocs getData(MvcResult result) throws IOException {
ApiResponseDto<EnumDocs> apiResponseDto = objectMapper
.readValue(result.getResponse().getContentAsByteArray(),
new TypeReference<ApiResponseDto<EnumDocs>>() {}
);
return apiResponseDto.getData();
}
}