728x90
반응형

개발 환경이 Windows라면, 도커 컨테이너는 wsl 기반이기 때문에 바인딩을 해주어야 한다.

참고로 windows와 wsl 환경에서 동시에 쓰일 수 없다.

 

1. usbipd 설치

먼저 Windows PowerShell을 관리자 모드로 키고 usbipd를 다운로드 받는다.

$ winget install usbipd

 

그 다음 USB를 연결하고 아래 명령어로 연결된 장치를 확인한다.

$ usbipd list

여기서 USB 직렬 장치(COM5) <- 장비를 쓸 예정인데, 지금 State를 보면 Not shared인 것을 확인할 수 있다.

이 State를 Shared로 바꿔야 wsl에서 인식할 수 있다. 

 

먼저 바인딩을 해준다. ( 위 사진의 BUSID 참고 )

$ usbipd bind --busid 2-4

 

그 다음 wsl attach를 해준다.

$ usbipd attach --wsl -b 2-4

 

우분투 shell에서 확인해보면, 

/# lsusb

 

이렇게 04d8:000a 주소가 잘 뜨는 것을 확인할 수 있다.

 

이제 Docker Container Spring boot에서 해당 데이터를 송 수신 해보자.

 

2. build.gradle 추가

dependencies {
	...
    implementation 'com.fazecast:jSerialComm:2.11.0'
    ...
}

 

3. 메서드 구현

read() 메서드를 @Scheduled를 사용한 스케줄링으로도 사용이 가능하다.

@Getter
@Slf4j
@Component
@Scope(scopeName = ConfigurableBeanFactory.SCOPE_PROTOTYPE)
public class UA10SerialImpl implements ThMeterSerial {

    private final static byte[] WRITE_DATA_BYTE_ARRAY = new byte[]{65, 84, 67, 68, 13, 10};
    @Setter
    @Getter
    public DeviceDto deviceDto;

    private SerialPort serialPort;

    public void connect() {
        try {
            if (serialPort == null) {
                serialPort = SerialPort.getCommPort(this.deviceDto.getSerialPort());
            }

            serialPort.setComPortParameters(9600, 8, SerialPort.ONE_STOP_BIT, SerialPort.NO_PARITY);
            serialPort.setComPortTimeouts(SerialPort.TIMEOUT_SCANNER, 0, 0);
            if (!serialPort.openPort()) {
                disconnect();
            }
            this.deviceDto.setIsConnected(true);
        } catch (Exception e) {
            log.error("[ Serial Connect Exception ] message : {}", e.getMessage());
        }
    }

    public void disconnect() {
        if (!serialPort.closePort()) {
            log.info("Failed to close port.");
        }
        serialPort.setComPortTimeouts(SerialPort.TIMEOUT_NONBLOCKING, 0, 0);
        serialPort = null;
        this.deviceDto.setIsConnected(false);
    }

    public synchronized void read() {
        if (!deviceDto.getIsConnected()) {
            return;
        }
        if (serialPort == null || serialPort.bytesAvailable() < 0) {
            connect();
            return;
        }
        serialPort.writeBytes(WRITE_DATA_BYTE_ARRAY,
            WRITE_DATA_BYTE_ARRAY.length);

        byte[] readBuffer = new byte[serialPort.bytesAvailable()];
        int numRead = serialPort.readBytes(readBuffer, readBuffer.length);

        if (numRead == 0) {
            return;
        }

        String receivedData = new String(readBuffer, 0, numRead);

        log.info("Received data: {}", receivedData);

    }
}

 

728x90
반응형
728x90
반응형

사용하는 Dto Class에 Enum Type이 존재하고, 타입 별로 필드를 검증하고 싶을 때 Annotation과 ConstraintValidator 인터페이스를 활용하여 Validaton을 구현할 수 있다.

 

1. ConditinalValidator Class

Conditional Class는 2번에서 만들 Annotation을 의미한다.

isValid 메소드에서 Required Value가 null이거나 ""일 때 에러를 반환해준다.

@Slf4j
public class ConditionalValidator implements ConstraintValidator<Conditional, Object> {

    private String selected;
    private String[] required;
    private String message;
    private String[] values;

    @Override
    public void initialize(Conditional requiredIfChecked) {
        selected = requiredIfChecked.selected();
        required = requiredIfChecked.required();
        message = requiredIfChecked.message();
        values = requiredIfChecked.values();
    }

    @Override
    public boolean isValid(Object objectToValidate, ConstraintValidatorContext context) {
        Boolean valid = true;
        try {
            Object actualValue = BeanUtils.getProperty(objectToValidate, selected);
            if (Arrays.asList(values).contains(actualValue)) {
                for (String propName : required) {
                    Object requiredValue = BeanUtils.getProperty(objectToValidate, propName);
                    // value가 null이거나 ""인지 판별한다.
                    valid = requiredValue != null && !isEmpty(requiredValue);
                    if (!valid) {
                        context.disableDefaultConstraintViolation();
                        context.buildConstraintViolationWithTemplate(message).addPropertyNode(propName).addConstraintViolation();
                    }
                }
            }
        } catch (IllegalAccessException e) {
            log.error("Accessor method is not available for class : {}, exception : {}", objectToValidate.getClass().getName(), e);
            e.printStackTrace();
            return false;
        } catch (NoSuchMethodException e) {
            log.error("Field or method is not present on class : {}, exception : {}", objectToValidate.getClass().getName(), e);
            e.printStackTrace();
            return false;
        } catch (InvocationTargetException e) {
            log.error("An exception occurred while accessing class : {}, exception : {}", objectToValidate.getClass().getName(), e);
            e.printStackTrace();
            return false;
        }
        return valid;
    }
}

 

2. Conditional Annotaton

Dto에 쓰이며 위에서 만든 ConditionalValidator Class를 활용하는 Annotaton이다.

@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = {ConditionalValidator.class})
public @interface Conditional {

    String message() default "This field is required.";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};

    String selected();
    String[] required();
    String[] values();
}

 

여기서 아래와 같이 @Repeatable 어노테이션으로 여러 개의 Conditianl Annotation을 같이 선언할 수도 있다.

@Repeatable(Conditionals.class)
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = {ConditionalValidator.class})
public @interface Conditional {

    String message() default "This field is required.";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};

    String selected();
    String[] required();
    String[] values();
}

 

@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE})
public @interface Conditionals {
    Conditional[] value();
}

 

3. DTO

사용이 필요한 DTO에 어노테이션을 추가해준다.

@Builder
@Getter
@Setter
@Conditional(selected = "locationOption", values = {"SFTP", "FTP"}, required = {"host", "user", "password", "port"})
@Conditional(selected = "locationOption", values = {"EMAIL"}, required = {"emailAddress"})
public class ExportLocationDTO {

    @NotEmpty
    private String name;
    @NotNull
    private ExportLocationOption locationOption;
    private String emailAddress;
    private String host;
    @Min(1)
    private Integer port;
    @NotBlank
    @Length(min = 1,max = 255)
    private String user;
    @NotBlank
    @Length(min = 1,max = 100)
    private String password;
}

 

 

2번에서 알려준 @Conditionals를 활용하면 다음과 같이 쓸 수 있다.

@Builder
@Getter
@Setter
@Conditionals(
	{
		@Conditional(selected = "locationOption", values = {"SFTP", "FTP"}, required = {"host", "user", "password", "port"}),
		@Conditional(selected = "locationOption", values = {"EMAIL"}, required = {"emailAddress"})
	}
)
public class ExportLocationDTO {

    @NotEmpty
    private String name;
    @NotNull
    private ExportLocationOption locationOption;
    private String emailAddress;
    private String host;
    private Integer port;
    private String user;
    private String password;
}

 

4. @Valid 적용

마지막으로 해당 DTO가 사용되는 곳에 @Valid 어노테이션을 추가해준다.

    @PutMapping
    public CommonResponse<Boolean> exportLocation(
        @Valid @RequestBody ExportLocationDTO dto
    ) {
        service.exportLocation(dto);
        return new CommonResponse<>(true);
    }

 

 

5. Test Code

@Valid 유닛 테스트 코드는 다음과 같이 작성할 수 있다.

Assertions.assertFalse(violations.isEmpty()); 메소드를 사용하면 @Valid 검증이 실패할 경우 테스트가 통과된다.

import jakarta.validation.Validation;
import jakarta.validation.Validator;
import jakarta.validation.ValidatorFactory;

public class ExportLocationDTOTest {

    private Validator validator;

    private ExportLocationDTO dto;

    @Before
    public void setUp() throws Exception {
        ValidatorFactory validatorFactory = Validation.buildDefaultValidatorFactory();
        validator = validatorFactory.getValidator();

        dto = ExportLocationDTO.builder()
                .name("validName")
                .build();
    }

    @Test
    public void ShouldFailOnMissingEmailAddressField() {
        dto.setLocationOption(ExportLocationOption.EMAIL);
        Set<ConstraintViolation<ExportLocationDTO>> violations = validator.validate(dto);
        assertEquals(1, violations.size());
    }

    @Test
    public void ShouldFailOnMissingFTPCredentials() {
        dto.setLocationOption(ExportLocationOption.FTP);
        Set<ConstraintViolation<ExportLocationDTO>> violations = validator.validate(dto);
        assertEquals(4, violations.size());
    }

    @Test
    public void ShouldFailOnMissingSFTPCredentials() {
        dto.setLocationOption(ExportLocationOption.SFTP);
        Set<ConstraintViolation<ExportLocationDTO>> violations = validator.validate(dto);
        assertEquals(4, violations.size());
    }
}

Assertions.assertFalse(violations.isEmpty());

 

 

아래는 Conditional 어노테이션 및 ConditionalValidator 클래스의 동작을 테스트한다.

LocaltionOption이 EMAIL일 경우 emailAddress 필드 값이 null이거나 빈 값이면 안되기 때문에 False를 검증하는 테스트이다.

public class ExportLocationDTOTest {

    private final ConstraintValidatorContext constraintValidatorContext = mock(
        ConstraintValidatorContext.class);

    private ExportLocationDTO dto;
    
    private conditionalValidator = new ConditionalValidator();

    @BeforeEach
    public void setUp(){

        dto = ExportLocationDTO.builder()
            .name("validName")
            .build();

        ConstraintAnnotationDescriptor.Builder<Conditional> descriptorBuilder = new ConstraintAnnotationDescriptor.Builder<>(
            Conditional.class);
        descriptorBuilder.setAttribute("selected", "locationOption");
        descriptorBuilder.setAttribute("values", new String[]{"EMAIL"});
        descriptorBuilder.setAttribute("required", new String[]{"emailAddress"});
        Conditional conditional = descriptorBuilder.build()
            .getAnnotation();
        conditionalValidator.initialize(conditional);
    }

    @Test
    public void ShouldFailOnMissingEmailAddressField() {
        dto.setLocationOption(ExportLocationOption.EMAIL);
        boolean valid = conditionalValidator.isValid(dto,
            constraintValidatorContext);
        assertFalse(valid);
    }
}
728x90
반응형
728x90
반응형

이번엔 로그인 뿐만 아니라 TOPT, Email을 사용한 복합 인증에 관하여 알아보자.

TOPT는 시간으로 OPT를 인증받는 방식으로, 구글, 마이크로 소프트 등 여러 앱으로 인증이 가능하다.

 

1. build.gradle 패키지 추가

implementation 'org.jboss.aerogear:aerogear-otp-java:1.0.0'

 

2. User Entity

  • isUsingMfa: 복합인증을 사용하는 유저인지 판단
  • mfaTypes: 어떤 복합인증을 사용하는 지 ( [ { mfaType: "EMAIL", registeredAt: "등록 시간" } ] )
  • secret: 복합인증에 사용되는 Base32 인코딩 값
    @Column(name = "is_using_mfa")
    private boolean isUsingMfa;

    @Column
    @Convert(converter = MfaTypesConverter.class)
    private List<MfaTypesDto> mfaTypes;

    @Column
    private String secret;

 

User가 만들어질 때 secret을 지정해준다.

    public long create(UserCreateDto createDto) {
        repository.findByUsernameAndDeletedAtIsNull(createDto.getUsername())
            .ifPresent(u -> {
                log.error("[This username already exists] Username: {}", createDto.getUsername());
                throw new IllegalArgumentException("This username already exists.");
            });
        if (createDto.getUsername().isBlank() | createDto.getPassword().isBlank()) {
            log.error("[ID or PW was entered incorrectly] Username: {}", createDto.getUsername());
            throw new IllegalArgumentException("ID or PW was entered incorrectly.");
        }

        String random = Base32.random();
        while (repository.findBySecretAndDeletedAtIsNull(random).isPresent()) {
            random = Base32.random();
        }

        createDto.setSecret(random);
        createDto.setPassword(passwordEncoder.encode(createDto.getPassword()));
        createDto.setGroups(setUserGroup(createDto.getGroupIds()).stream().toList());
        return repository.save(new User(createDto)).getId();
    }

 

3. MfaTypesDto

@Data
@NoArgsConstructor
@AllArgsConstructor
public class MfaTypesDto {

    private MfaType mfaType;
    private LocalDateTime registeredAt;
}

 

4. MfaType Enum

public enum MfaType {
    OTP,
    EMAIL
}

 

5. MfaTypesConverter

@Converter
public class MfaTypesConverter implements AttributeConverter<List<MfaTypesDto>, String> {

    private final ObjectMapper mapper;

    public MfaTypesConverter(ObjectMapper mapper) {
        this.mapper = mapper;
    }

    @Override
    public String convertToDatabaseColumn(List<MfaTypesDto> attribute) {
        try {
            return mapper.writeValueAsString(attribute);
        } catch (JsonProcessingException e) {
            throw new RuntimeException(e);
        }
    }

    @Override
    public List<MfaTypesDto> convertToEntityAttribute(String dbData) {
        try {
            return mapper.readValue(dbData, new TypeReference<List<MfaTypesDto>>() {
            });
        } catch (JsonProcessingException e) {
            throw new RuntimeException(e);
        }
    }
}

 

여기까지 엔티티에 대한 구성을 완료 하였고, 이제 부터 필요한 것은 

  • 복합 인증 사용자가 로그인을 할 경우 쿠키 및 세션을 제공하지 않는다.
  • 복합 인증 사용자는 인증을 거쳐야 쿠키 및 세션이 등록된다.

일단 필요한 Dto들을 먼저 보자면,

 

6. MfaEmailSendRequestDto

이메일 전송 시 Request

@Data
@AllArgsConstructor
@NoArgsConstructor
@EventLoggingDto
public class MfaEmailSendRequestDto {

    @NotNull
    private String email;
}

 

7. MfaEmailVerifyRequestDto

이메일 검증 시 Request

@Data
@AllArgsConstructor
@NoArgsConstructor
public class MfaEmailVerifyRequestDto {

    @NotNull
    private String email;
    
    @NotNull
    private String code;

}

 

8. MfaOtpVerifyDto

Otp 검증 시 Request

@Data
@AllArgsConstructor
@NoArgsConstructor
public class MfaOtpVerifyDto {

    @NotNull
    private String email;

    private String code;

}

 

 

9. ResponseLoginDto

로그인 성공 시 Response

@Data
@AllArgsConstructor
public class ResponseLoginDto {

    private boolean isUsingMfa;
    private List<MfaType> mfaTypes;
    private MfaType currentMfaType;
    private Long id;
    private String name;
    private String firstName;
    private String lastName;
    private List<String> groupNames;
    private List<String> roles;
    private Long exp;

    public ResponseLoginDto(
        boolean isUsingMfa,
        List<MfaType> mfaTypes,
        Long id,
        String name,
        String firstName,
        String lastName,
        List<String> groupNames,
        List<String> roles,
        Long exp
    ) {
        MfaType currentMfaType = null;
        if (mfaTypes.contains(MfaType.OTP)) {
            currentMfaType = MfaType.OTP;
        } else if (mfaTypes.contains(MfaType.E_MAIL)) {
            currentMfaType = MfaType.E_MAIL;
        }
        this.isUsingMfa = isUsingMfa;
        this.mfaTypes = mfaTypes;
        this.currentMfaType = currentMfaType;
        this.id = id;
        this.name = name;
        this.firstName = firstName;
        this.lastName = lastName;
        this.groupNames = groupNames;
        this.roles = roles;
        this.exp = exp;

    }
}

 

10. UserService

  1. registerMfaTypes: 유저의 MfaTypes 정보를 새로 등록한다.
  2. deleteMfaTypes: 유저의 MfaTypes 중 원하는 것을 제거한다.
  3. registerAuthenticator: OTP 인증 중 secret 값을 만들고 유저에 등록하며 클라이언트로 보내주는 로직
  4. verifyAuthenticatorCode: TOPT 검증하는 로직
  5. sendAuthenticationCodeByEmail: 이메일 전송 로직 ( Spring Boot | Custom 메일 보내기 ( with JavaMailSender ) :: 티포의개발일지 (tistory.com) ) 참고
  6. certEmail: 이메일 검증 로직
  7. emailCertNumberMap: 이메일 검증 시에 쓰이며, Dto에는 code와 createdAt이 있다. 검증 로직에서 아래 상수로 시간이 지나면 검증이 실패하게끔 만들어준다. 이 포스트에서는 인메모리로 다루어봤다. 
  8. 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);
    }


}

 

클라이언트 QR code 예시

`otpauth://totp/typosblog:${username}?secret=${secretQrCode}&issuer=typosblog`
728x90
반응형
728x90
반응형

Spring Boot | MockMvc with Spring Security and RestDocs :: 티포의개발일지 (tistory.com)

 

Spring Boot | MockMvc with Spring Security and RestDocs

Spring에서 API 문서를 만들 때 Swegger, Restdocs를 쓴다. 가장 큰 차이는 Restdocs는 테스트코드가 필수라는 점이다. TDD가 처음엔 귀찮은 작업일 순 있지만 향후 유지보수를 위해선 오히려 나를 편하게

typo.tistory.com

 

기존 포스트에서 RestDocs에 관하여 글을 썼던 적이 있다. 이 글은 Restdocs만을 위한 최적화된 테스트였다.

 

원래도 RestDocs와 Swagger를 같이 쓰고 싶었지만, Swagger에 관한 코드를 Controller에서 따로 써야하고, RestDocs와 Swagger를 따로 관리해야 하는 불편함이 있었다.

 

독일 기업 epages에서 RestDocs와 Swagger를 연동하여 OpenApi Spec(기존 Swagger Spec)을 만들어주는 Restdocs-api-spec을 만들어서 이 전보다 훨씬 쉽게 연동할 수 있게 되었다.

ePages-de/restdocs-api-spec: Adds API specification support to Spring REST Docs (github.com)

 

GitHub - ePages-de/restdocs-api-spec: Adds API specification support to Spring REST Docs

Adds API specification support to Spring REST Docs - ePages-de/restdocs-api-spec

github.com

 

이 글은 전에 올린 포스트에서의 수정 사항까지 같이 알려주고자 한다.

 

1. build.gradle

// 1. buildscript 추가
buildscript {
    ext {
        restdocsApiSpecVersion = '0.18.4'
    }
}

plugins {
    id 'java'
    id 'org.springframework.boot' version '3.1.2'
    id 'io.spring.dependency-management' version '1.1.0'
    // 2. 기존 id "org.asciidoctor.jvm.convert" version "3.3.2" 삭제
    // 3. restdocs-api-spec 추가
    id 'com.epages.restdocs-api-spec' version "${restdocsApiSpecVersion}"
}

group = 'com.teepo'
version = '0.0.1-SNAPSHOT'

repositories {
    mavenCentral()
    google()
}

java {
    sourceCompatibility = JavaVersion.VERSION_17
}

jar {
    enabled = false
}

sourceSets {
    main {
        resources {
            srcDirs = ['src/main/resources']
        }
    }
}

configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
    // 4. 기존 asciidoctorExt 삭제
}

dependencies {
    annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jakarta"
    annotationProcessor "jakarta.annotation:jakarta.annotation-api"
    annotationProcessor "jakarta.persistence:jakarta.persistence-api"
    annotationProcessor 'org.mapstruct:mapstruct-processor:1.5.3.Final'
    annotationProcessor 'org.projectlombok:lombok'
    annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'

    compileOnly 'org.projectlombok:lombok'

    developmentOnly 'org.springframework.boot:spring-boot-devtools'

    implementation 'org.yaml:snakeyaml:2.2'
    implementation 'software.amazon.awssdk:route53domains:2.25.40'
    implementation 'com.bucket4j:bucket4j-core:8.3.0'
    implementation 'com.google.guava:guava:33.1.0-jre'
    implementation 'com.intelligt.modbus:jlibmodbus:1.2.9.10'
    implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'
    implementation 'io.hypersistence:hypersistence-utils-hibernate-60:3.3.2'
    implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
    implementation 'org.apache.commons:commons-csv:1.10.0'
    implementation 'org.apache.commons:commons-lang3:3.14.0'
    implementation 'org.flywaydb:flyway-core:9.8.1'
    implementation 'org.hibernate:hibernate-core:6.1.6.Final'
    implementation 'org.mapstruct:mapstruct:1.5.3.Final'
    implementation 'org.springframework.boot:spring-boot-configuration-processor'
    implementation 'org.springframework.boot:spring-boot-starter-actuator'
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-mail'
    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'org.springframework.boot:spring-boot-starter-validation'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.session:spring-session-core:3.1.2'
    implementation 'org.springframework.session:spring-session-jdbc:3.1.2'
    implementation 'org.springframework.kafka:spring-kafka'
    implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
    implementation 'org.xhtmlrenderer:flying-saucer-pdf:9.7.2'
    implementation 'org.graalvm.buildtools:native-gradle-plugin:0.9.27'
    implementation 'net.java.dev.jna:jna:5.7.0'
    implementation 'org.apache.httpcomponents:httpclient:4.5.14'

    runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
    runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'
    runtimeOnly 'org.postgresql:postgresql'

    testAnnotationProcessor 'org.projectlombok:lombok'

    testCompileOnly 'org.projectlombok:lombok'

    testImplementation 'junit:junit:4.13.1'
    testImplementation 'org.springframework.kafka:spring-kafka-test'

    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'org.springframework.security:spring-security-test'

    // 5. 기존 asciidoctorExt 'org.springframework.restdocs:spring-restdocs-asciidoctor' 삭제
    // 6. 기존 spring-restdocs-mockmvc 유지
    testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc'
    // 7. openAPI3 추가
    testImplementation 'com.epages:restdocs-api-spec-mockmvc:' + restdocsApiSpecVersion
}

tasks.named('test') {
    useJUnitPlatform()
    // 8. 기존 outputs.dir snippetsDir 삭제
}


// 9. OpenApi3 서버 실행
openapi3 {
    setServer("http://localhost:8080")
    title = 'Post Service API'
    description = 'Post Service API description'
    version = '1.0.0'
    format = 'yaml'
}

// 10. 그 밖에 기존 대비 삭제 되는 것들
//ext {
//    set('snippetsDir', file("build/generated-snippets"))
//}
//
//task copyDocument(type: Copy) {
//    dependsOn asciidoctor
//    from file("build/docs/asciidoc")
//    into file("src/main/resources/static/docs")
//}
//
//tasks.named('asciidoctor') {
//    inputs.dir snippetsDir
//    dependsOn test
//}
// asciidoctor {//RestDoc
//    inputs.dir snippetsDir
//    configurations 'asciidoctorExt'
//    dependsOn test
//}

 

 

2. RestDocs 테스트 코드 작성

RestDocs 테스트 코드 기반으로  진행되어야 한다. 이는 전 포스트의 내용과 거의 동일하기 때문에 복사한 뒤 변경 사항을 알려주겠다.

 

여기서 중요한 점은 

2.1 기존 MockMvcRestDocumentation.document 사용 시 

MockMvcRestDocumentation.document 이런식으로 사용되던 것들을

MockMvcRestDocumentationWrapper.document 이렇게 바꿔야 한다.

 

import org.springframework.restdocs.mockmvc.MockMvcRestDocumentation ->

import com.epages.restdocs.apispec.MockMvcRestDocumentationWrapper 

 

import 부분을 바꾸면 된다.

 

2.2 기존 MockMvcRequestBuilders.post 사용 시 (httpMethod 전부 동일)

예를 들어, mockMvc.perform(MockMvcRequestBuilders.delete("/group")  이런 것들을

this.mockMvc.perform(RestDocumentationRequestBuilders.delete("/group")

이렇게 바꿔야 한다.

 

2.3 기존 RestDocumentationResultHandler restdocs 사용 금지 

아직 이유를 찾지 못했지만 restdocs.document 이런 식으로 사용하던 메소드는 

queryParameters를 인식하지 못한다.

 

 

 

테스트 코드를 작성해보자. 

 

 

andDo(document()) 부분을 삭제하는 코드를 만들어준다.

이 코드는 모든 테스트에서 extends 될 것이다.

@SpringBootTest
@AutoConfigureRestDocs
@AutoConfigureMockMvc
@Disabled
@Import(RestDocsConfig.class)
@ExtendWith(RestDocumentationExtension.class)
public class RestDocsTestSupport {

    @Autowired
    protected MockMvc mockMvc;

    @Autowired
    protected ObjectMapper objectMapper;

    @Autowired
    protected JwtUtil jwtUtil;

    @BeforeEach
    void setUp(final WebApplicationContext context,
        final RestDocumentationContextProvider provider) {
        this.mockMvc = MockMvcBuilders.webAppContextSetup(context)
            .apply(
                MockMvcRestDocumentation.documentationConfiguration(provider))  // rest docs 설정 주입
            .alwaysDo(MockMvcResultHandlers.print()) // andDo(print()) 코드 포함 
            .addFilters(new CharacterEncodingFilter("UTF-8", true)) // 한글 깨짐 방지
            .build();
    }
}

 

여기서 만약 Spring Security를 적용하면 다음과같이 써야한다.

@BeforeEach
    void setUp(final WebApplicationContext context,
        final RestDocumentationContextProvider provider) throws Exception {

        DelegatingFilterProxy delegateProxyFilter = new DelegatingFilterProxy();

        delegateProxyFilter.init(
            new MockFilterConfig(context.getServletContext(),
                BeanIds.SPRING_SECURITY_FILTER_CHAIN));

        this.mockMvc = MockMvcBuilders.webAppContextSetup(context)
            .apply(
                MockMvcRestDocumentation.documentationConfiguration(provider))  // rest docs 설정 주입
            .alwaysDo(MockMvcResultHandlers.print()) // andDo(print()) 코드 포함
            .addFilters(
                new CharacterEncodingFilter("UTF-8", true),
                delegateProxyFilter
            )
            .build();
    }

 

Session을 사용한다면, 다음과 같은 방법을 사용할 수도 있다.

@BeforeEach
    void setUp(final WebApplicationContext context,
        final RestDocumentationContextProvider provider) throws Exception {

        DelegatingFilterProxy delegateProxyFilter = new DelegatingFilterProxy();

        delegateProxyFilter.init(
            new MockFilterConfig(context.getServletContext(),
                BeanIds.SPRING_SECURITY_FILTER_CHAIN));

        this.mockMvc = MockMvcBuilders.webAppContextSetup(context)
            .apply(
                MockMvcRestDocumentation.documentationConfiguration(provider))  // rest docs 설정 주입
            .alwaysDo(MockMvcResultHandlers.print()) // andDo(print()) 코드 포함
            .addFilters(
                new CharacterEncodingFilter("UTF-8", true),
                delegateProxyFilter
            )
            .build();
            
        User user = new User(
            new UserCreateDto(
                "test",
                passwordEncoder.encode("test1234")
            )
        );
        em.persist(user);
        em.flush();
        em.clear();
        
        this.login();
    }
    
    private void login() throws Exception {
        var loginDto = new RequestLoginDto();
        loginDto.setId("admin");
        loginDto.setPassword("root1234");
        var login = mockMvc.perform(RestDocumentationRequestBuilders.post("/auth/login")
            .content(objectMapper.writeValueAsString(loginDto))
            .contentType(MediaType.APPLICATION_JSON));
        this.session = (MockHttpSession) login.andReturn().getRequest().getSession();
    }

    protected MockHttpSession getSession() {
        return this.session;
    }

 

아래는 예시 코드이다.

class MemberControllerTest extends RestDocsTestSupport {

    @Test
    void findAll() throws Exception {
        this.mockMvc.perform(RestDocumentationRequestBuilders.get("/user")
                .session(this.getSession())
                .with(user("admin")
                    .roles("USER_ADMIN")))
            .andExpect(status().isOk())
            .andDo(document("{class-name}/{method-name}",
            		preprocessRequest(prettyPrint()),
                    preprocessResponse(prettyPrint()),
                    responseFields(
                        fieldWithPath("data").type(ARRAY).description("data"),
                        fieldWithPath("data[].id").type(NUMBER)
                            .description("id"),
                        fieldWithPath("data[].username").type(STRING)
                            .description("username"),
                        fieldWithPath("data[].createdAt").type(STRING)
                            .description("createdAt"),
                        fieldWithPath("data[].updatedAt").type(STRING)
                            .description("updatedAt")
                    )
                )
            );
    }
}

 

7. Request, Response Fields 커스터마이징

/test/resources/org/springframework/restdocs/templates/asciidoctor/ 경로에 snippet 파일을 추가하면 default 표를 만들 수 있다.

/default-request-fields.snippet

|===
|필드|타입|필수값|설명|제한

{{#fields}}
|{{path}}
|{{type}}
|{{^optional}}true{{/optional}}
|{{description}}
|{{#constraint}}{{constraint}}{{/constraint}}

{{/fields}}
|===

constraints 같은 경우는 다음과 같이 추가할 수 있다.

fieldWithPath("name").description("name").attributes(field("constraints", "길이 10 이하"))

 

/default-response-fields.snippet

|===
|필드|타입|설명

{{#fields}}
|{{path}}
|{{type}}
|{{description}}

{{/fields}}
|===

/default-path-parameters.snippet, /default-query-parameters.snippet

|===
|파라미터|설명

{{#parameters}}
|{{name}}
|{{description}}

{{/parameters}}
|===

 

8. Enum 코드 문서화

Enum 타입을 사용하기 위해선 로직에 사용하던 enum을 다음 interface에 implement 해주어야 한다.

RestDocsEnumType.java

public interface RestDocsEnumType {

    String getName();

    String getDescription();

}

 

아래는 예시 코드.

MemberStatus.java

@AllArgsConstructor
public enum MemberStatus implements RestDocsEnumType {
    LOCK("일시 정지"),
    NORMAL("정상"),
    BAN("영구 정지");

    private final String description;

    @Override
    public String getDescription() {
        return this.description;
    }

    @Override
    public String getName() {
        return this.name();
    }
}

 

위는 참고했던 사이트에서 만든 파일들이고,

내가 직접 만들었던 파일들이다.

 

Enum에 관련한 snippet인 custom-response-fields.snippet을 만들어준다.

|===
|필드|타입|설명

{{#fields}}
|{{path}}
|{{type}}
|{{description}}

{{/fields}}
|===

 

위 패키지 경로대로 하나씩 클래스를 만들어준다.

 

utils/consts/CustomResponseFieldsSnippet

public class CustomResponseFieldsSnippet extends AbstractFieldsSnippet {

    public CustomResponseFieldsSnippet(String type,
        PayloadSubsectionExtractor<?> subsectionExtractor,
        List<FieldDescriptor> descriptors, Map<String, Object> attributes,
        boolean ignoreUndocumentedFields) {
        super(type, descriptors, attributes, ignoreUndocumentedFields,
            subsectionExtractor);
    }

    @Override
    protected MediaType getContentType(Operation operation) {
        return operation.getResponse().getHeaders().getContentType();
    }

    @Override
    protected byte[] getContent(Operation operation) throws IOException {
        return operation.getResponse().getContent();
    }
}

 

utils/consts/ApiResponseDto

@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();
    }
}

 

이렇게 만들고 빌드를 하면 아래와 같은 adoc 파일이 생기는데

 

Enum을 클릭해서 새 창을 띄우도록 하려면 아래와 같이 사용할 수 있다.

queryParameters(
                        parameterWithName("memberStatus").description(
                            "link:#enum-memberStatus[memberStatus,window=\"_blank\"]"),

 

 

3. Swagger 접목하기

swagger를 위해 build/api-spec/openapi3.json 파일이 필요하다.

build를 실행할 때의 테스트가 실행되어야 하며, 통과 됐을 경우 아래와 같이 json 파일이 만들어진다.

{
  "openapi" : "3.0.1",
  "info" : {
    "title" : "Post Service API",
    "description" : "Post Service API description",
    "version" : "1.0.0"
  },
  "servers" : [ {
    "url" : "http://localhost:8080"
  }, {
    "url" : "http://production-api-server-url.com"
  } ],
  "tags" : [ ],
  "paths" : {
    "/posts" : {
      "get" : {
        "tags" : [ "posts" ],
        "operationId" : "get-posts",
        "parameters" : [ {
          "name" : "page",
          "in" : "query",
          "description" : "페이지 번호",
          "required" : true,
          "schema" : {
            "type" : "string"
          }
        }, {
          "name" : "size",
          "in" : "query",
          "description" : "한 페이지의 데이터 개수",
          "required" : true,
          "schema" : {
            "type" : "string"
          }
        }, {
          "name" : "sort",
          "in" : "query",
          "description" : "정렬 파라미터,오름차순 또는 내림차순 +\nex) +\ncreatedDate,asc(작성일 오름차순) +\ncreatedDate,desc(작성일 내림차순)",
          "required" : true,
          "schema" : {
            "type" : "string"
          }
        } ],
        ...

 

 

이제 docker-compose에 다음과 같이 추가한 뒤 실행하면 http://localhost:8090에 Swagger가 뜨는 것을 확인할 수 있다.

  server-swagger:
    image: swaggerapi/swagger-ui
    ports:
      - "8090:8080"
    environment:
      SWAGGER_JSON: /tmp/openapi3.json
    volumes:
      - .\server\build\api-spec\:/tmp
    depends_on:
      - server
      - db

 

 

4. CORS 설정

@Configuration
@Import({
    AopConfig.class,
})
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/**")
            .allowedOrigins("http://localhost:8090", "http://localhost:3001")
            .allowedMethods("GET", "POST", "PUT", "DELETE", "PATCH")
            .allowedHeaders("*")
            .allowCredentials(true);
    }
}

 

5. AuthTokenFilter

Spring Security에서 해당 필터를 사용중이라면, 다음 조건을 걸어주자.

@Slf4j
public class AuthTokenFilter extends OncePerRequestFilter {

    @Autowired
    private JwtUtil jwtUtil;
    @Autowired
    private UserDetailServiceImpl userDetailsService;

    private String parseJwt(HttpServletRequest request) {
        return jwtUtil.getJwtFromCookies(request);
    }

    //매 인증시 JWT 필터 로직
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
        FilterChain filterChain) throws ServletException, IOException {
        try {
            String jwt = parseJwt(request);
            if (Objects.equals(request.getHeader("origin"), "http://localhost:8090")) {
                String username = "admin";

                UserDetails userDetails = userDetailsService.loadUserByUsername(username);

                UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
                    userDetails,
                    null,
                    userDetails.getAuthorities());

                authentication.setDetails(
                    new WebAuthenticationDetailsSource().buildDetails(request));

                SecurityContextHolder.getContext().setAuthentication(authentication);
            }
            if (jwt != null && jwtUtil.validateJwtToken(jwt)) {
                String username = jwtUtil.getUsernameFromJwtToken(jwt);

                UserDetails userDetails = userDetailsService.loadUserByUsername(username);

                UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
                    userDetails,
                    null,
                    userDetails.getAuthorities());

                authentication.setDetails(
                    new WebAuthenticationDetailsSource().buildDetails(request));

                SecurityContextHolder.getContext().setAuthentication(authentication);
            }
        } catch (Exception e) {
            log.error("Cannot set user authentication: {}", e.getMessage());
        }

        filterChain.doFilter(request, response);
    }
}
728x90
반응형
728x90
반응형

회사마다 다르겠지만, 본인은 아래와 같은 방법으로 생각을 해봤다.

  1. 이슈 등록
  2. 작업 후 PR 등록 ( 빌드 / 테스트 자동화, 라벨링 자동화 )
  3. Main에 Merge
  4. 적절한 시기에 release/0.0.1 같은 브랜치 생성
  5. release 브랜치에 main 병합 ( 릴리즈 자동화로 버전 관리)
  6. 적절한 시기에 배포 ( 배포 자동화 )

 

다른 자동화 workflow는 이미 이전 포스트에 등록을 했으니, 이번엔 릴리즈 자동화를 하고자 한다.

아래는 이번 포스트에 쓰일 Release Drafter action이다.

 

Release Drafter · Actions · GitHub Marketplace

 

Release Drafter - GitHub Marketplace

Drafts your next release notes as pull requests are merged into master

github.com

 

1. /.github/workflows/release_drafter.yml 생성

name: Auto Labeling

on:
  pull_request:
    branches:
      - main

permissions:
  contents: write
  pull-requests: write
  packages: write

jobs:
  update_release_draft:
    runs-on: self-hosted
    steps:
      - uses: release-drafter/release-drafter@v6
        with:
          config-name: release_drafter_config.yml
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

 

2. /.github/release_drafter_config.yml 생성

name-template: 'v$RESOLVED_VERSION 🌈'
tag-template: 'v$RESOLVED_VERSION'
categories:
  - title: '🚀 Features'
    labels:
      - 'feature'
      - 'enhancement'
  - title: '🐛 Bug Fixes'
    labels:
      - 'fix'
      - 'bugfix'
      - 'bug'
  - title: '🧰 Maintenance'
    label: 'chore'
change-template: '- $TITLE @$AUTHOR (#$NUMBER)'
change-title-escapes: '\<*_&' # You can add # and @ to disable mentions, and add ` to disable code blocks.
version-resolver:
  major:
    labels:
      - 'major'
  minor:
    labels:
      - 'minor'
  patch:
    labels:
      - 'patch'
  default: patch
template: |
  ## Changes

  $CHANGES

 

참고로 config 파일은 해당 브랜치에 병합이 되어야 인식할 수 있다.

자세한 설명은 위 사이트에서 확인 가능하다.

728x90
반응형
728x90
반응형

workflow를 사용하는 방법은 매우 간단하다

프로젝트/.github/workflows/ 경로 아래에 yml 파일을 만들면 인식해서 실행시켜준다.

 

1. /.github/workflows/auto_labeling.yml 생성

name: Auto Labeling

on:
  pull_request:
    types: [ opened, reopened, synchronize ]

permissions:
  contents: write
  pull-requests: write
  packages: write

jobs:
  update_release_draft:
    runs-on: self-hosted
    steps:
      - uses: release-drafter/release-drafter@v6
        with:
          commitish: main
          config-name: auto_labeling_config.yml
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

 

pull_request가 실행 될 때마다 작동하게 설계했으며, config 파일의 경로는 .github/ 이어야 한다.

권한 또한 중요하니 잊지 않도록 한다.

 

runs-on은 환경에 맞는 코드를 쓰면 된다.

 

2. /.github/auto_labeling_config.yml 작성

template: |
  ## What’s Changed

  $CHANGES
autolabeler:
  - label: 'Component: Client'
    files:
      - 'client/**'
  - label: 'Type: Bug'
    title:
      - '/^fix(\([a-zA-Z][a-zA-Z]\))?:/i'

template 은 필수 코드 이므로 추가하고, 경로와 PR title을 사용한 규칙을 지정하도록 한다.

사실상 template은 해당 작업에서 사용되진 않으며, 릴리즈 자동화에서 쓰일 예정이다.

title의 정규 표현식은 원하는대로 바꿀 수도 있다.

728x90
반응형
728x90
반응형

 

1. 액세스 토큰 생성

Personal Access Tokens (Classic) (github.com)  에서 패키지에 대한 권한을 가진 토큰을 생성한다.

생성되는 토큰 값을 저장한 뒤 원하는 곳에 txt 파일로 만들어두고, 아래 명령어 중 하나로 로그인 가능하다.

$ docker login https://ghcr.io -u outsideris // 입력 후 패스워드 토큰값 입력
$ cat TOKEN.txt | docker login https://ghcr.io -u outsideris --password-stdin

 

2. self_host runner를 만들고 등록해준다.

About self-hosted runners - GitHub Docs

 

About self-hosted runners - GitHub Docs

You can host your own runners and customize the environment used to run jobs in your GitHub Actions workflows.

docs.github.com

 

3. workflow를 만들어준다.

# This workflow uses actions that are not certified by GitHub.
# They are provided by a third-party and are governed by
# separate terms of service, privacy policy, and support
# documentation.
# This workflow will build a package using Gradle and then publish it to GitHub packages when a release is created
# For more information see: https://github.com/actions/setup-java/blob/main/docs/advanced-usage.md#Publishing-using-gradle

name: Workflow

permissions:
  contents: read
  packages: write
  checks: write

# 어떤 이벤트가 발생하면 workflow 실행할 지 명시
on:
  # main 브랜치에 pull_request 발생 시
  pull_request:
    branches:
      - main


# 위 이벤트 발생 시 실행될 작업들
jobs:
  # 빌드 후 Container Registry에 image 등록
  push_to_registry:
    # VM의실행 환경 지정 => self-hosted
    runs-on: self-hosted

    # 실행될 jobs 순서 대로 명시
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Postgres Start
        env:
          ENV_FILE: ${{ secrets.ENV_FILE }}
        run: echo "$ENV_FILE" > ./.env &&
          docker image prune &&
          docker rm -f $(docker ps -qa) &&
          docker compose up db -d

      # JDK 17 설치
      - name: Set up JDK 17
        uses: actions/setup-java@v4
        with:
          java-version: '17'
          distribution: 'temurin'

      # Gradle Build를 위한 권한 부여
      - name: Grant execute permission for gradlew
        run: chmod +x ./gradlew

      # Gradle Build
      - name: Build with Gradle
        run: ./gradlew clean build

      # 테스트 결과를 PR에 코멘트로 달아줌
      - name: Publish test result
        uses: EnricoMi/publish-unit-test-result-action@v2.15.1
        if: always()
        with:
          files: '**/build/test-results/test/*.xml'

      # 테스트 실패 시 해당 코드 라인에 코멘트 달아줌.
      - name: When test fail, a comment is registered in the error code line.
        uses: mikepenz/action-junit-report@v4.1.0
        if: always()
        with:
          report_paths: '**/build/test-results/test/*.xml'

      # GitHub Container Registry 로그인
      - name: Login to GitHub Container Registry
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ secrets.GHUB_USERNAME }}
          password: ${{ secrets.GHUB_TOKEN }}

      # Docker image 빌드 및 push
      - name: Server Build and push
        uses: docker/build-push-action@v5
        with:
          context: .
          file: ./server/Dockerfile.prod
          push: true
          tags: ghcr.io/${{ secrets.GHUB_USERNAME }}/server:${{ secrets.RELEASE_VERSION }}

      - name: Client Build and push
        uses: docker/build-push-action@v5
        with:
          context: ./client
          file: ./client/Dockerfile.prod
          push: true
          tags: ghcr.io/${{ secrets.GHUB_USERNAME }}/client:${{ secrets.RELEASE_VERSION }}

      - name: Nginx Build and push
        uses: docker/build-push-action@v5
        with:
          context: ./docker/nginx
          push: true
          build-args: BUILD_ENV=prod
          tags: ghcr.io/${{ secrets.GHUB_USERNAME }}/nginx:${{ secrets.RELEASE_VERSION }}

      - name: DB Build and push
        uses: docker/build-push-action@v5
        with:
          context: ./docker/postgres
          push: true
          tags: ghcr.io/${{ secrets.GHUB_USERNAME }}/db:${{ secrets.RELEASE_VERSION }}

  # 서버에 배포
  deploy_via_ssh:
    needs: push_to_registry
    runs-on: self-hosted

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      # Env 추가
      - name: Add Env
        env:
          ENV_FILE: ${{ secrets.ENV_FILE }}
          GHUB_USERNAME: ${{ secrets.GHUB_USERNAME }}
          RELEASE_VERSION: ${{ secrets.RELEASE_VERSION }}
        run: echo "$ENV_FILE" > ./.env &&
          echo -e "GHUB_USERNAME=$GHUB_USERNAME \nRELEASE_VERSION=$RELEASE_VERSION" >> ./.env

      # .env, docker-compose.prod.yml 파일 scp로 서버에 전송
      - name: copy files to target server via scp
        uses: appleboy/scp-action@v0.1.7
        with:
          host: ${{ secrets.DEPLOY_HOST }}
          username: ${{ secrets.DEPLOY_USERNAME }}
          key: ${{ secrets.DEPLOY_KEY }}
          port: ${{ secrets.DEPLOY_PORT }}
          source: ./docker-compose.prod.yml, ./.env
          target: /home

      #  원격으로 docker compose up 실행
      - name: remote docker-compose up via ssh
        uses: appleboy/ssh-action@v1.0.3
        with:
          host: ${{ secrets.DEPLOY_HOST }}
          username: ${{ secrets.DEPLOY_USERNAME }}
          key: ${{ secrets.DEPLOY_KEY }}
          port: ${{ secrets.DEPLOY_PORT }}
          envs: APPTOKEN,USERNAME
          script: |
            echo ${{ secrets.GHUB_TOKEN }} | sudo docker login ghcr.io --username ${{ secrets.GHUB_USERNAME }} --password-stdin &&
            cd /home &&
            sudo docker pull ghcr.io/${{ secrets.GHUB_USERNAME }}/server:${{ secrets.RELEASE_VERSION }} &&
            sudo docker pull ghcr.io/${{ secrets.GHUB_USERNAME }}/client:${{ secrets.RELEASE_VERSION }} &&
            sudo docker pull ghcr.io/${{ secrets.GHUB_USERNAME }}/db:${{ secrets.RELEASE_VERSION }} &&
            sudo docker pull ghcr.io/${{ secrets.GHUB_USERNAME }}/nginx:${{ secrets.RELEASE_VERSION }}

 

 

4. Dockefile

ghcr(컨테이너 레지스트리)에서 image를 pull 하므로 배포용 Docker 파일을 다음과같이 바꿔야 한다.

    app:
        image: ghcr.io/${IMAGE_REPO}:${RELEASE_VERSION}

 

5. secrets

github repository -> Settings -> Secrets and variables에서 secrets 항목들을 추가해준다.

참고로 github username은 반드시 소문자로 해야된다.


self-hosted 로 사용할 경우 서버의 사용자에 Docker 권한을 부여해주어야 한다.

 

How to fix docker: Got permission denied issue - Stack Overflow

 

How to fix docker: Got permission denied issue

I installed Docker in my machine where I have Ubuntu OS. When I run: sudo docker run hello-world All is ok, but I want to hide the sudo command to make the command shorter. If I write the command

stackoverflow.com

 

 

참고

GitHub Action Docker Compose deployments via SSH (servicestack.net)

 

728x90
반응형
728x90
반응형

이번 포스트에서는 Dockerfile의 환경 ( dev or prod )에 따라 Nginx를 다르게 빌드하는 법을 알아보겠다.

 

1. dev.default.conf 생성

upstream client{
  server client:3000;
}

upstream server{
  server server:8080;
}

server {
    listen       80;
    listen  [::]:80;
    server_name  localhost;

    #access_log  /var/log/nginx/host.access.log  main;
    location /api {
        proxy_pass http://server;
        proxy_redirect off;
        rewrite ^/api/(.*)$ /$1 break;
    }

    location / {
        root   /usr/share/nginx/html;
        index  index.html index.htm;
        proxy_pass http://client;
    }

    #error_page  404              /404.html;

    # redirect server error pages to the static page /50x.html
    #
    error_page   500 502 503 504  /50x.html;
    location = /50x.html {
        root   /usr/share/nginx/html;
    }
}

 

2,  prod.default.conf 생성

upstream client{
  server client:3000;
}

upstream server{
  server server:8080;
}

server {
    listen       80;
    listen  [::]:80;
    server_name  [도메인이름];

    location ^~ /.well-known/acme-challenge/ {
      default_type "text/plain";
      root /var/www/certbot;
    }

    location / {
        return 301 https://[도메인이름]$request_uri;
    }
}

server {

        listen 443 ssl default_server;
        listen [::]:443 ssl default_server;
        server_name  [도메인이름];

        ssl_certificate /etc/nginx/ssl/live/[도메인이름]/fullchain.pem;
        ssl_certificate_key /etc/nginx/ssl/live/[도메인이름]/privkey.pem;

        #access_log  /var/log/nginx/host.access.log  main;
        location /api {
            proxy_pass http://server;
            proxy_redirect off;
            rewrite ^/api/(.*)$ /$1 break;
            proxy_http_version 1.1;
            proxy_set_header Upgrade $http_upgrade;
            proxy_set_header Connection 'upgrade';
            proxy_set_header Host $host;
        }

        location / {
            proxy_pass http://client;
            root   /usr/share/nginx/html;
            index  index.html index.htm;
            proxy_http_version 1.1;
            proxy_set_header Upgrade $http_upgrade;
            proxy_set_header Connection 'upgrade';
            proxy_set_header Host $host;
        }

        #error_page  404              /404.html;

        # redirect server error pages to the static page /50x.html
        #
        error_page   500 502 503 504  /50x.html;
        location = /50x.html {
            root   /usr/share/nginx/html;
        }

        #allow deny ip
        #allow 12.0.0.0;
        #deny all;
}

 

3. .env 변수 추가

# Build 환경 변수 설정
BUILD_ENV=dev

 

4. docker compose 파일 수정

services:
  nginx:
    build:
      context: ./docker/nginx
      args:
        BUILD_ENV: ${BUILD_ENV} // 이 부분 추가
    ports:
      - "80:80"
      - "443:443"
    restart: always
    environment:
      TZ: Asia/Seoul
      BUILD_ENV: ${BUILD_ENV}
    volumes:
      - ${CERTBOT_WWW_DIR}:/var/www/certbot/:ro
      - ${CERTBOT_CONF_DIR}:/etc/nginx/ssl/:ro
    depends_on:
      - client
      - server

 

5. Dockerfile 수정

FROM nginx:latest

ARG BUILD_ENV=BUILD_ENV

COPY ./$BUILD_ENV.default.conf /etc/nginx/conf.d/default.conf

CMD ["nginx", "-g", "daemon off;"]
728x90
반응형
728x90
반응형

이전 포스트와 다르게 Docker를 활용한 SSL 인증을 구현해보았다. Cerbot 컨테이너를 만들고, docker compose 명령어를 통해 동작

1. /docker/nginx/default.conf 생성

최초 certbot 인증 시에는 아래와 같이 기본 세팅으로 해야한다.

server {
    listen 80;
    listen [::]:80;

    server_name 도메인 이름;
    server_tokens off;

    location /.well-known/acme-challenge/ {
        root /var/www/certbot;
    }

}

 

2. /docker/nginx/ Dockerfile 생성

FROM nginx:latest
COPY ./default.conf /etc/nginx/conf.d/default.conf

CMD ["nginx", "-g", "daemon off;"]

 

3. docker-compose.yml 추가

services:
  nginx:
    build: ./docker/nginx
    ports:
      - "80:80"
      - "443:443"
    restart: always
    environment:
      TZ: Asia/Seoul
    volumes:
      - ${CERTBOT_WWW_DIR}:/var/www/certbot/:ro
      - ${CERTBOT_CONF_DIR}:/etc/nginx/ssl/:ro
    depends_on:
      - client
      - server

  certbot:
    image: certbot/certbot:latest
    volumes:
      - ${CERTBOT_WWW_DIR}:/var/www/certbot/:rw
      - ${CERTBOT_CONF_DIR}:/etc/letsencrypt/:rw
      
  client:
  	...
  server:
  	...

 

4. .env파일 작성

...

# CERTBOT
CERTBOT_WWW_DIR=/data/certbot/www
CERTBOT_CONF_DIR=/data/certbot/conf

5. Docker compose up

$ docker compose up -d

인증서 발급을 위해 Nginx 서버를 켜준다.

6. 인증서 발급

$ docker compose run --rm  certbot certonly --webroot --webroot-path /var/www/certbot/ -d 도메인이름

 

pem 키 위치 확인

 

7. default.conf 파일 수정

upstream client{
  server client:3000;
}

upstream server{
  server server:8080;
}

server {
    listen       80;
    listen  [::]:80;
    server_name  [도메인이름];

    location ^~ /.well-known/acme-challenge/ {
      default_type "text/plain";
      root /var/www/certbot;
    }

    location / {
        return 301 https://[도메인이름]$request_uri;
    }
}

server {

        listen 443 ssl default_server;
        listen [::]:443 ssl default_server;
        server_name  [도메인이름];

        ssl_certificate /etc/nginx/ssl/live/[도메인이름]/fullchain.pem;
        ssl_certificate_key /etc/nginx/ssl/live/[도메인이름]/privkey.pem;

        #access_log  /var/log/nginx/host.access.log  main;
        location /api {
            proxy_pass http://server;
            proxy_redirect off;
            rewrite ^/api/(.*)$ /$1 break;
            proxy_http_version 1.1;
            proxy_set_header Upgrade $http_upgrade;
            proxy_set_header Connection 'upgrade';
            proxy_set_header Host $host;
        }

        location / {
            proxy_pass http://client;
            root   /usr/share/nginx/html;
            index  index.html index.htm;
            proxy_http_version 1.1;
            proxy_set_header Upgrade $http_upgrade;
            proxy_set_header Connection 'upgrade';
            proxy_set_header Host $host;
        }

        #error_page  404              /404.html;

        # redirect server error pages to the static page /50x.html
        #
        error_page   500 502 503 504  /50x.html;
        location = /50x.html {
            root   /usr/share/nginx/html;
        }

        #allow deny ip
        #allow 12.0.0.0;
        #deny all;
}

 

8. docker 재시작

$ docker compose up -d

Nginx Service에 restart:always 옵션이 존재하기 때문에 Nginx Service는 재시작된다.

 

9. 인증서 관련 명령어

 

- 인증서 확인

$ docker compose run --rm certbot certificates

 

- 인증서 갱신

$ docker compose run --rm certbot renew

 

10. 크론 탭 활용 (crontab)

- /bin/letsencrypt.sh 파일 작성

cd /home
date >> /home/certbot_renew.log
sudo docker compose run --rm certbot renew >> /home/certbot_renew.log
sudo docker compose restart nginx

 

- 실행 권한 부여

$ sudo chmod +x /bin/letsencrypt.sh

 

- 크론탭 열고 편집 

$ sudo crontab -e

 

- 아래 배치잡 생성

30 4 * * 0 /bin/letsencrypt.sh

 

- 저장 후 배치잡 확인

$ sudo crontab -l

 

-저장하고 크론 다시 실행

$ sudo service cron restart
728x90
반응형
728x90
반응형

1. rsync 다운로드

$ sudo yum install rsync

# 혹은

$ sudo apt install rsync

 

2. 사용법

$ rsync [OPTIONS] [SOURCE] [TARGET]

 

3. 예제

# 로컬 데이터를 로컬에 복사
$ rsync -avh /home/user/data /home/new_user/backup

# 로컬의 데이터를 리모트로 복사 
$ rsync -avh /home/user/data remote_user@remotehost:/home/remote_user/backup

# ssh 포트가 다른 경우
$ rsync -avh -e "ssh -p 123" /home/user/data remote_user@remotehost:/home/remote_user/backup

# 리모트 데이터를 로컬로 가져옴
$ rsync -avh remote_user@remotehost:/home/remote_user/backup /home/user/data

 

-ravPh 옵션을 주면 Progress도 확인할 수 있다.

 

4. ssh 파일 전송 예제

rsync -ravPh abc.tar Server:~/
728x90
반응형

+ Recent posts