[TIL]

 

Graphviz

Please join the Graphviz forum to ask questions and discuss Graphviz. What is Graphviz? Graphviz is open source graph visualization software. Graph visualization is a way of representing structural information as diagrams of abstract graphs and networks. I

graphviz.org

( Graphviz 의 공식 사이트 )

 

 

 

구현 목표)

Graphviz 라는 노드들을 연결하여 그래프를 그려주는 툴을 기반으로 사용자의 앱 이동 경로를 시각화하기로 하였다.

 

Graphviz 는 공식적으로는 java 를 지원하지 않지만 감사하게도(?) 자바를 통해 객체 형태로 노드들을 관리하고 시각화할 수 있게끔 오픈소스로 Graphviz 를 변형해둔 깃허브 주소를 발견해서 해당 소스를 토대로 개발을 진행하였다.

 

⬇️ Graphviz in JAVA 오픈소스 Github ⬇️

 

GitHub - nidi3/graphviz-java: Use graphviz with pure java

Use graphviz with pure java. Contribute to nidi3/graphviz-java development by creating an account on GitHub.

github.com

 

 

 

 

구현 과정)

우선, 결과부터 얘기해보자면 아래에 기술하는 첫번째의 방법으로는 구현을 실패하였고 차선책으로 사용한 두번째 구현 방법으로 구현에 성공하였다.

 

 

 

1️⃣ 순수 자바코드로 구현 시도

 

해당 오픈소스 사용 방법으로는 해당 오픈소스 의존성을 추가하고 자바 코드로 노드를 시각화하려면 아래와 같이 가변형 그래프객체(그래프에 노드를 추가하는 형식이 아니라 한번에 그래프를 생성하는 경우는 MutableGraph 객체를 사용하지 않아도 된다)를 생성하고 가변형 노드객체를 선언하여 추가하는 형식으로 이용하면 된다.

 

<dependency>
    <groupId>guru.nidi</groupId>
    <artifactId>graphviz-java</artifactId>
    <version>0.18.1</version>
</dependency>

( 의존성 추가 )

import guru.nidi.graphviz.attribute.Color;
import guru.nidi.graphviz.engine.Format;
import guru.nidi.graphviz.engine.Graphviz;
import guru.nidi.graphviz.model.MutableGraph;
import guru.nidi.graphviz.model.MutableNode;
import static guru.nidi.graphviz.model.Factory.*;

import java.io.File;
import java.io.IOException;

public class GraphExample {
    public static void main(String[] args) {
    
        try {
            // MutableGraph 객체 생성
            MutableGraph g = mutGraph("example1").setDirected(true);

            // 개별 노드 생성
            MutableNode nodeA = mutNode("a").add(Color.RED);
            MutableNode nodeB = mutNode("b");

            // 노드를 그래프에 추가
            g.add(nodeA);
            g.add(nodeB);

            // 노드 간 링크 추가
            nodeA.addLink(nodeB);

            // 그래프를 PNG로 렌더링 후 파일로 저장
            Graphviz.fromGraph(g).width(200).render(Format.PNG).toFile(new File("example/ex1m.png"));
            
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

( JAVA 코드로 객체화하여 그래프를 생성하는 방법 )

 

여기서 각 객체를 선언 및 사용하는 부분에서는 Graphviz 모듈을 사용하지 않지만 .render() 메소드 부분에서는 직접적으로 Graphviz 의 모듈 중 하나인 dot 명령어를 사용하므로 Graphviz 를 프로젝트가 돌아갈 운영체제에 설치한다.

 

⬇️ MAC, Linux Graphviz 설치 방법 ⬇️

# ⬇️ MAC 설치 ⬇️
brew install graphviz

# ⬇️ Linux 설치 (Debian 계열 - Ubuntu) ⬇️
sudo apt install graphviz

# ⬇️ Linux 설치 (Redhat 계열) ⬇️
sudo dnf install graphviz

 

그리고 코드를 실행시켜주기만 하면 되는데 ( dot is not defined ) 오류가 이클립스의 콘솔에서 발생하였고 Graphviz 를 설치했음에도 이클립스에서 프로젝트를 실행할 경우 내장된 dot 명령어를 찾지 못해 환경변수 설정까지 진행을 시켜봤지만 동일한 오류가 계속 발생하였다.

( 이클립스 프로그램 내의 환경변수 설정도 지정해줬지만 동일하게 dot 명령어를 찾지 못했다. )

 

혹시 몰라 터미널에서 dot -V 명령어를 통해 dot 명령어가 정상적으로 실행되는지 확인해보았지만 터미널에서는 정상작동하는 것을 볼 수 있었다.

그래서 다음 방법을 찾아보다가 아래와 같은 방법으로 구현을 진행하게 되었다.

 

 

 

 

 

2️⃣ 자바에서 shell 명령어를 사용하여 구현

 

public class GraphExample {
    public static void main(String[] args) {
    
        ArrayList<String> routerTypeList = new ArrayList<>();
        ArrayList<String> currentPathList = new ArrayList<>();
        String command = "export PATH=\"$PATH:/usr/local/bin\"; echo 'digraph {";
        
        
        try {
            for (int i = 0; i < 앱이동경로MapList.size(); i++) {
                routerTypeList.add(앱이동경로MapList.routerType);
                // 쉘에서는 경로를 나타내는 / 를 사용하면 오류를 뱉기 때문에
                // 이동경로(ex. /home) 과 같은 \ 를 추가하여 이스케이프 문자로 처리함
                if(앱이동경로MapList.currentPath.charAt(0) == '\\') {
                    currentPathList.add('\"' + 앱이동경로MapList.currentPath + '\"');
                }else {
                    currentPathList.add('\"' + 앱이동경로MapList.currentPath + '\"');
                }
            }
			
			for(int i=0; i<currentPathList.size(); i++) {
				if(i==0) {
                	// 앱 내 이동경로가 1개인 경우 경로를 추가하지 않고 node 하나만 출력하기 위해
                    // 커맨드에 해당 노드 경로만 추가
					if(currentPathList.size() == 1) {
						command += currentPathList.get(0);
					}
					continue;
				}else {
                	// 만약 커맨드 내에 노드 -> 노드(ex. a -> b) 이동이 이미 중복 선언되어 있다면
                    // 중복 이동 그래프를 그리는 것을 방지하기 위해 커맨드를 추가하지 않음
                    // 이 부분을 제거하면 모든 중복 경로이동의 화살표가 표현됨
					if(!command.contains(currentPathList.get(i-1) + "->" + currentPathList.get(i) + " ")) {							
						command += (currentPathList.get(i-1) + "->" + currentPathList.get(i) + " ");
					}
				}
				
			}
			
            // svg 파일을 생성할때 사용할 랜덤한 uuid 를 생성
            // API 요청은 비동기로 작동하기 때문에 중복 요청의 경우의 수를 생각
			String uuid = UUID.randomUUID().toString();
            
            // sh shell 로 전송할 커맨드를 작성
            // -Tsvg 옵션으로 svg 파일로 그래프를 생성
            // -Nshape=rect 로 모든 노드를 사각형으로 표현
            // -o {파일명} 을 통해 파일의 이름 선언
			command += "}' | dot -Tsvg -Nshape=rect -o " + uuid + ".svg;";
			
            // 명령어를 실행할 ProcessBuilder 객체를 생성
			ProcessBuilder builder = new ProcessBuilder();
			String homeDirectory = System.getProperty("user.dir");
			log.debug("##### homeDirectory ::: {}", homeDirectory);
			File graphDir = new File(homeDirectory, "graph");
			if(!graphDir.exists()) {
				if(!graphDir.mkdir()) {
					log.error("##### fail to mkdir ::: {}", graphDir.getAbsolutePath());
				}
			}
			
			builder.directory(graphDir);
			
            // sh 쉘로 커맨드 실행
			builder.command("sh", "-c", command);
			Process process = builder.start();
			
            // 에러 발생 시 커스텀된 StreamGobbler 를 사용하여 sh shell 내의 에러를 읽어
            // 콘솔에 출력함
			StreamGobbler errorGobbler = new StreamGobbler(process.getErrorStream(), System.err::println);
			Executors.newSingleThreadExecutor().submit(errorGobbler);
			
			log.debug("##### success make graph ::: {}/{}.svg", graphDir.getAbsolutePath(), uuid);
			
			int exitCode = process.waitFor();
			assert exitCode == 0;
			
            // 생성된 파일을 읽어와 Base64 로 인코딩하여 문자열로 변환한 뒤 응답
			File graphFile = new File(graphDir, uuid + ".svg");
			if(graphFile.exists()) {
				try {
					byte[] graphContent = Files.readAllBytes(graphFile.toPath());
					String encodedGraph = Base64.getEncoder().encodeToString(graphContent);
					message = encodedGraph;
				}catch(IOException e) {
					message = "";
					e.printStackTrace();
				}
			}else {
				message = "";
			}
			
            // 생성된 svg 파일을 삭제 및 삭제되지 않은 경우 log 출력
			if(!graphFile.delete()) {
				log.debug("##### fail to delete graphFile ::: {}", graphFile.getAbsolutePath());
			}
		} catch (Exception e) {
			// TODO Auto-generated catch block
			e.printStackTrace();
			message = e.getMessage();
		} finally {
		}
		return returnJson(message);
    }
}

( 그래프 생성 코드 )

import java.io.BufferedReader;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.util.function.Consumer;

public class StreamGobbler implements Runnable {
	
	private InputStream inputStream;
	private Consumer<String> consumer;
	
	public StreamGobbler(InputStream inputStream, Consumer<String> consumer) {
		this.inputStream = inputStream;
		this.consumer = consumer;
	}
	
	@Override
	public void run() {
		new BufferedReader(new InputStreamReader(inputStream)).lines().forEach(consumer);
	}
	
}

( 쉘 내 에러를 받아올 StreamGobbler 커스텀 )

 

 

 

⬇️ 결과물 ⬇️

앱 별 이동경로가 정상적으로 그래프로 표현된 모습 ( 앱 경로는 블러처리 )

 

 

 

 

 

728x90

 

728x90

 

 

 

 

 

- TIL -

구현 목표)

타임리프를 사용해서 간단하게 회원을 관리할 수 있는 백오피스 페이지를 구현해보려 한다.

 

타임리프(Thymeleaf)란?

타임리프는 Java 기반의 서버사이드 템플릿 엔진으로써 현재 지원이 거의 중단되어버린 JSP 의 대안으로 떠오르는 템플릿 엔진이다.

JSP 가 jar 형태의 빌드를 지원하지 않고 war 형태의 빌드만 지원했던 반면 타임리프는 jar 와 war 형태의 빌드를 모두 지원한다.

또한 서버가 없는 서버리스 형태로도 템플릿을 실행시킬 수 있다는 장점이 있다.

 

 

 

구현 코드)

 

- login.html, login.js -

더보기
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" lang="en">
<head>
    <meta charset="UTF-8">
    <title>Admin Login</title>
    <link rel="stylesheet" th:href="@{/css/login/login.css}">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body>
    <div class="login-container">
        <h2>Admin Login</h2>
        <form id="login-form">
            <div class="input-group">
                <label for="username">Username</label>
                <input type="text" id="username" name="username" required>
            </div>
            <div class="input-group">
                <label for="password">Password</label>
                <input type="password" id="password" name="password" required>
            </div>
            <button type="submit">Login</button>
        </form>
        <div id="error-message" class="error-message" style="display: none;"></div>
    </div>

    <script th:src="@{/js/login/login.js}"></script>
</body>
</html>

login.html

( 로그인 정보를 form 태그로 담아 전송 )

 

 

 

document.getElementById("login-form").addEventListener("submit", async function (event) {
    event.preventDefault();

    const username = document.getElementById("username").value;
    const password = document.getElementById("password").value;

    try {
        const response = await fetch("https://api.random-chat.site/admin/login", {
            method: "POST",
            headers: { "Content-Type": "application/x-www-form-urlencoded" },
            body: `username=${username}&password=${password}`
        });

        if (response.ok) {
            const token = response.headers.get("Authorization").replace("Bearer ", "");
            localStorage.setItem("jwtToken", token); // JWT 저장
            window.location.href = "/admin/dashboard"; // 대시보드로 리디렉션
        } else {
            const errorMessage = document.getElementById("error-message");
            errorMessage.textContent = "Invalid username or password";
            errorMessage.style.display = "block";
        }
    } catch (error) {
        console.error("Error during login:", error);
    }
});

login.js

( 로그인 정보를 담아서 성공 시 토큰을 반환받아 토큰정보를 Local Storage 에 jwtToken 키로 저장 후 /admin/dashboard 경로로 리디렉션 )

 

 

 

- LoginController -

더보기
package com.randomchat.main.controller.backOffice;

import com.randomchat.main.domain.users.Users;
import com.randomchat.main.jwt.JWTUtil;
import com.randomchat.main.service.backOffice.AdminLoginService;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;

@Controller
@RequiredArgsConstructor
public class LoginController {

    private final AdminLoginService adminLoginService;
    private final JWTUtil jwtUtil;

    @GetMapping("/admin/login")
    public String showLoginPage() {
        // template 디렉토리 하위의 login.html 렌더링
        return "login/login";
    }

    // 로그인 처리
    @PostMapping("/admin/login")
    public ResponseEntity<String> handleLogin(@RequestParam("username") String username,
                                              @RequestParam("password") String password,
                                              HttpServletResponse response) {

        Users user = adminLoginService.adminLogin(username, password);

        if(user != null) {
            // jwt 생성하여 반환
            String jwtToken = jwtUtil.createJwt(user.getEmail(), user.getNickname(), user.getRole().name(), user.getGender().name(), 60*60*1000L);
            response.addHeader("Authorization", "Bearer " + jwtToken);

            return ResponseEntity.ok("Success login");
        }else {
            // 로그인 실패
            return ResponseEntity.status(401).body("Fail login");
        }

    }
}

api.random-chat.site/admin/login 경로로 접근 시 login.html 리턴

유저 정보가 있는 경우 jwtToken 정보를 생성하여 접두사 Bearer 를 붙여 Header 에 담아 리턴

 

 

 

- AdminLoginService -

더보기
package com.randomchat.main.service.backOffice;

import com.randomchat.main.domain.users.Role;
import com.randomchat.main.domain.users.Users;
import com.randomchat.main.repository.users.UsersRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;

import java.util.Optional;

@Service
@RequiredArgsConstructor
public class AdminLoginService {

    private final UsersRepository userRepository;
    private final BCryptPasswordEncoder bCryptPasswordEncoder;

    public Users adminLogin(String username, String password) {
        Optional<Users> optionalUser = userRepository.findByEmail(username);
        if(optionalUser.isEmpty()) {
            System.out.println("AdminLoginService.class >>> 해당 이메일로 가입된 로그인 정보가 없음");
            return null;
        }else {
            Users user = optionalUser.get();
            if(bCryptPasswordEncoder.matches(password, user.getPassword()) && user.getRole().equals(Role.ADMIN)) {
                System.out.println("AdminLoginService.class >>> admin 권한을 가진 유저(" + user.getEmail() + ") 로그인 성공");
                return user;
            }else {
                System.out.println("AdminLoginService.class >>> 로그인 권한을 가지지 않은 유저가 로그인 시도 : " + user.getEmail());
                return null;
            }
        }
    }
}

 userRepository 를 사용해 입력된 Email 정보로 가입된 유저를 먼저 탐색

> 가입된 유저가 없다면 null 반환

> 가입된 유저가 있지만 Role 값이 ADMIN 이 아닌 경우 null 반환

> 가입된 유저이면서 Role 값이 ADMIN 인 경우 유저 객체 반환

 

 

 

- SecurityConfig -

더보기

 백오피스 로그인 페이지 경로와 css, js 파일의 경로를 렌더링하기 위해 전체 허용 설정

 

이후 dashboard 하위의 기능은 /admin/dashboard 하위 경로로 관리할 예정

 

전체 접근은 가능하게 설정하고 세부 기능 api 에서 Role 값을 기준으로 요청을 걸러낼 예정이다.

 

그렇다면 실제 관리자 dashboard 에서는 페이지 로드 시 토큰 값을 백엔드 측으로 보내 관리자인지 검증하게끔 하는 코드를 추가하여

관리자 경로의 일반 유저의 접근을 차단하는 방법의 구현이 필요하다.

 

 

 

728x90

 

 

 

728x90

 

 

 

 

 

- TIL -

구현 목표)

타임리프를 사용해서 간단하게 회원을 관리할 수 있는 백오피스 페이지를 구현하고 있는데 이때 관리자를 토큰 값을 기준으로 판별하는 코드가 추가되었다.

이 코드를 테스트하기 위해 미리 관리자를 생성할 필요가 있었고 서버 실행 시점에 초기화 작업을 위해 사용되는 @PostContruct 어노테이션을 활용해서 기본 admin 계정을 생성해보고자 했다.

 

 

 

구현 코드)

package com.randomchat.main.service;

import com.randomchat.main.domain.users.Users;
import com.randomchat.main.repository.users.UsersRepository;
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Component;

@Component
@RequiredArgsConstructor
public class CreateDefaultAdmin {

    private final BCryptPasswordEncoder bCryptPasswordEncoder;
    private final UsersRepository usersRepository;

    @PostConstruct
    public void createDefaultAdmin() {

        // 관리자 계정이 존재하는 경우 관리자 계정 생성을 패스
        if(usersRepository.findByEmail("admin").isPresent()) return;

        String email = "admin";
        String password = bCryptPasswordEncoder.encode("admin");
        String nickname = "admin";

        Users user = new Users();
        Users admin = user.createAdmin(email, password, nickname);

        usersRepository.save(admin);
    }

}

CreateDefaultAdmin.class 를 생성하여 컴포넌트로 선언

 

createDefaultAdmin() 메소드를 생성하여 @PostConstruct 어노테이션을 붙여줌으로써 CreateDefaultAdmin 빈이 초기화 되고 의존성 주입이 완료된 시점에 해당 메소드를 실행하게 설정

 

서버 실행 시 시작되는 코드이므로 admin 계정이 이미 존재하는 경우(서버를 재부팅하거나 재배포하는 과정) 관리자 계정을 생성하지 않고 넘어가게끔 if 문을 사용해서 관리

 

 

 

 

 

728x90

 

 

 

 

 

- TIL -

문제 발생)

package com.randomchat.main.domain.email;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.CreationTimestamp;

import java.time.LocalDateTime;

@Entity
@Getter
@NoArgsConstructor
public class EmailVerification {
    public EmailVerification(String email, String verificationCode) {
        this.email = email;
        this.verificationCode = verificationCode;
        this.isVerified = false;
    }

    @Id
    @GeneratedValue
    private Long id;

    @Column
    private String email;

    @Column
    private String verificationCode;

    @Column(nullable = false)
    private boolean isVerified;

    @CreationTimestamp
    @Column(nullable = false)
    private LocalDateTime attemptTime;

}

위 코드는 현재 이메일 인증을 관리하는 테이블의 구조이다.

 

attemptTime 컬럼은 이메일 인증을 시도한 시점의 시간을 저장하는 컬럼인데 5분 내 이메일 인증 시도 횟수를 아래와 같이 COUNT 를 사용하여 출력해보려 하였지만 몇 번을 요청하든 0회로 찍혀서 나오는 오류가 발생하였다.

네이티브 쿼리를 활용하여 현재 시간의 5분 내에 특정 이메일로 요청한 데이터의 갯수를 출력하도록 메소드를 작성

 

혹시 Hibernate 에서 쿼리를 Insert 하는 순간에 attempt_time 컬럼이 비어있는 상태로 넘어가는지 로그를 확인해보았지만 정상적으로 Insert 는 실행되는 모습이다.

정상적으로 attempt_time 컬럼의 값이 채워져서 insert 되는 모습

 

@CreateTimeStamp 어노테이션을 사용하면 jpa 를 사용하여 save() 메소드를 사용해 객체의 데이터를 데이터베이스에 저장하는 시점에 Hibernate 가 해당 어노테이션을 인식하고 자동으로 값을 채워넣어 주는 형태이기 때문에 save() 메소드를 사용해 저장하는 나는 당연하게도 값이 누락될 리는 없었다.

 

 

 

 

 

저장된 값을 확인해보려 MySQL 을 실행하였다.

( 로그로 확인할 수 없는 이유는 save() 메소드 실행 시 값이 채워지기 때문에 생성된 객체의 getter 를 사용해서 데이터를 뽑아도 출력되지 않기 때문 )

id 값이 202 ~ 352 까지가 오늘 요청한 부분

 

MySQL 을 열어 확인해보니 attempt_time 의 값은 채워지는데 시간대의 설정이 올바르게 되어있지 않는 듯 하였다.

 

우선적으로 attempt_time 의 값을 채워주는 Hibernate 는 JDBC 의 시간대 설정을 따른다고 검색을 통해 알아냈다.

 

application.yml 의 url 항목 설정을 확인해보니 serverTimezone 이 UTC(세계 협정 기준시) 로 설정되어 있는 것을 확인할 수 있었다.

 

JDBC 의 serverTimezone 을 한국의 시간대로 변경

 

시간대를 서울 기준으로 변경한 뒤 다시 api 요청을 보내보니 정상적인 한국 시간 기준으로 attempt_time 이 저장되는 것을 확인할 수 있었다.

 

한국 표준시로 정확하게 저장되는 것 확인, API 요청시 인증 횟수 5회 초과 요청시 문구 응답

728x90

 

 

 

 

 

- TIL -

문제 발생)

잘 구현해 놓았던 JWT 토큰을 검증하던 로직에서 아래와 같은 에러가 발생하였다.

발생된 오류

 

해당 내용에 대해 구글링을 해보니 이 에러의 원인으로 의심되는 부분이 한 군데 발견되었다.

 

바로 authenticate 메소드를 가진 객체인 AuthenticationManager 객체인데 이 부분에서 순환 참조 오류가 발생한 것을 확인할 수 있었다.

 

구글링 결과 AuthenticationManager 의 경우 프록시 객체 상태로 사용하면 안되고 명시적으로 메소드를 사용하여 객체를 생성하여 반환한 뒤에 사용해야

한다는 것을 확인했다.

 

그렇지만 나는 아래와 같이 이미 SecurityConfig.class 에 명시적으로 authenticationManager 메소드를 통해 AuthenticationManager 를 빈 객체로

등록해 놓은 상황인데.... 이 객체를 사용하는 부분에서 내가 선언한 객체가 아닌 프록시 객체를 사용한다는 부분을 확인하게 되었다.

 

 

 

SecurityConfig.class)

더보기
package com.randomchat.main.config.security;

import com.randomchat.main.jwt.JWTFilter;
import com.randomchat.main.jwt.JWTUtil;
import com.randomchat.main.jwt.LoginFilter;
import com.randomchat.main.repository.users.UsersRepository;
import com.randomchat.main.service.jwt.CustomUserDetailsService;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;

import java.util.List;

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
    // AuthenticationManager 가 인자로 받을 AuthenticationConfiguration 객체 주입
    private final AuthenticationConfiguration authenticationConfiguration;
    private final JWTUtil jwtUtil;
    private final UsersRepository usersRepository;
    private final CustomUserDetailsService customUserDetailsService;


    @Bean
    public BCryptPasswordEncoder bCryptPasswordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    // AuthenticationManager Bean 등록
    public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {
        return configuration.getAuthenticationManager();
    }

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                // 폼 로그인 비활성화
                .formLogin(formLogin -> formLogin.disable())

                // HTTP Basic 인증 비활성화
                .httpBasic(httpBasic -> httpBasic.disable())

                // CSRF 보호 비활성화 (Token 인증 방식에서는 불필요)
                .csrf(csrf -> csrf.disable())

                // CORS 커스텀을 적용하는 메소드
                .cors(c -> {
                    CorsConfigurationSource source = request -> {
                        CorsConfiguration config = new CorsConfiguration();
                        config.setAllowedOriginPatterns(List.of("*"));
                        config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE"));
                        config.setAllowedHeaders(List.of("*"));
                        config.setExposedHeaders(List.of("*"));

                        UrlBasedCorsConfigurationSource corsSource = new UrlBasedCorsConfigurationSource();
                        corsSource.registerCorsConfiguration("/**", config);
                        return config;
                    };
                    c.configurationSource(source);
                })

                // 세션 비활성화
                .sessionManagement(session -> session
                        .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                )

                // H2 콘솔 접근 허용
                .headers(headers -> headers
                        .frameOptions(frameOptions -> frameOptions.sameOrigin())
                )

                // 경로별 권한 인증 설정
                .authorizeRequests(auth -> auth
                    // 메모리를 사용하는 H2 데이터베이스의 접속 경로를 오픈
                    // http://localhost:8080/h2-console 경로로 콘솔 접근 가능
                    .requestMatchers("/h2-console/**", "/login", "/register/**").permitAll()
                    .requestMatchers("/chat/**", "/room/**", "/send/**", "/enter/**").permitAll() // 채팅 관련 엔드포인트 접속 허용 설정
                    .requestMatchers("/admin").hasRole("ADMIN")
                    .anyRequest().authenticated()
                )

                .addFilterAfter(new JWTFilter(jwtUtil), LoginFilter.class)

                // 커스텀 로그인 필터를 등록
                .addFilterAt(new LoginFilter(authenticationManager(authenticationConfiguration), jwtUtil, usersRepository), UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }
}

 

 

 

그렇다면 여기서 프록시 객체를 사용하지 않고 현재 사용방식과 다른 방식으로 AuthenticationManager 객체를 명시적으로 선언하여 사용하는 방법에 대해 구글링을 해보았다.

 

 

Spring boot3 - AuthenticationManager + Stackoverflow feat. UserDetailsService

사건의 발단.. 이번에도 팀 시큐리티를 맡게 되었다 이번엔 기필코 깔끔하게 하겠다는 생각으로 임하게 되었는데.. 각 파트 다 짜고 이제 로그인해보자! 하고 들어가는 순간...!!!! 아.... 제일 보

hbyun.tistory.com

( 문제를 해결할 수 있게끔 도와주신 블로그에 무한한 감사를 드립니다.... )

 

 

 

정말 친절하게도 위의 블로그에서 DaoAuthenticationProvider 라는 객체를 사용해서 AuthenticationManager 를 생성하는 방법에 대해 설명해주고 있었고 나는 이 방법을 사용해보기로 하였는데 뭔가 이상함을 느꼈다....

 

위 블로그에서 작성해놓은 방법은 UserDetailsService 를 커스텀 구현한 서비스와 커스텀된 비밀번호 인코더가 필요한 부분이었는데 현재

SecurityConfig.class 에 비밀번호 인코더는 BCryptPasswordEncoder 객체를 반환하는 메소드를 통해 사용중인데 아무리 찾아봐도 이전에

구현해놓은 커스텀 CustomUserDetailsService 클래스가 보이질 않는다....

 

난 분명히 UserDetailsService 인터페이스를 구현한 CustomUserDetailsService 구현체를 작성했던 기억이 나는데 도통 보이질 않는다...

 

혹시 몰라 깃허브의 커밋 로그를 들어다 보았다.

 

 

 

초기 구현 부분에는 CustomUserDetailsService 가 존재한다.

 

 

 

스프링 시큐리티를 커스텀하면서 CustomUserDetailsService 클래스를 푸쉬했었고, 그 다음 JWT 토큰 커스텀 커밋 로그까지도 해당 클래스는

살아있었는데 이메일 인증 로직을 커밋한 로그를 살펴보니 아래와 같이 해당 서비스가 삭제되어 있는 모습을 확인할 수 있었다.

 

 

 

CustomUserDetailsService 가 삭제된 모습

 

 

 

아마도 코드를 정리하다가 CustomUserDetailsService 를 삭제한 모양이다....

 

위 클래스가 없으니 당연히 아래의 authenticationManager 메소드에서도 객체를 생성하여 반환할때 UserDetailsService 구현체를 불러오지 못해

프록시 객체로 동작 되는 것이 당연하였다....

 

삭제된 CustomUserDetailsService 파일을 다시 작성한뒤 메소드는 건들이지 않고 로직을 실행시켜 보았고 성공적으로 토큰을 받아오는 것을 확인할 수 있었다.

DaoAuthenticationProvider 객체를 사용하지 않고 기존 코드 유지

 

요청이 성공하여 토큰 정보를 헤더에 담아 응답하는 모습

 

 

 

랜덤 채팅을 구현하면서 화상 랜덤 채팅을 구현할때 토큰 값으로 사용자를 분류할 생각이었는데 이 오류를 잡는데 2주가 넘는 시간이 소비되었다...

이제 다시 토큰 값으로 사용자를 구분할 수 있게 되었다!

728x90

 

 

 

 

 

- TIL -

구현 목표)

현재 프로젝트의 경우 www.random-chat.site 로 접속 시에는 정상적으로 React 프로젝트의 루트 경로가 정상적으로 서빙되지만

www.randocm-chat.site/randomchat 과 같은 경로는 정상적으로 React 프로젝트로 서빙되지 않고 아래와 같이 Nginx 의 404 페이지가 서빙되는

문제가 발생했다.

세부 경로를 표현하면 React 프로젝트의 내부 경로로 서빙되지 않는 모습

 

오늘은 세부 경로를 기입하고 이동할때 정상적으로 세부 경로로 서빙되게끔 nginx 를 세팅해 볼 것이다.

 

 

 

docker-compose.yml 파일 설정 변경)

더보기

 

세부 경로의 경우 nginx 에서 서빙해주는 부분이다 보니 직접적으로 nginx 의 html 부분의 파일들을 react 의 파일들과 매핑해주는 작업을

해야했다.

 

위와 같이 nginx 컨테이너 내부의 /usr/share/nginx/html 경로에 리액트의 빌드 파일을 볼륨으로 잡아 동일한 리액트 빌드 파일을 nginx 컨테이너에도 위치시켰다.

 

 

 

Nginx 의 nginx.conf 파일 설정 변경)

더보기

nginx.conf 작성

기존 코드 >> 변경 코드

 

 nginx.conf 파일 내에 리액트 프로젝트로 서빙해주는 location / 부분에

 

root 경로를 명시적으로 표현해주고, index 파일 또한 명시적으로 표현해주었다.

 

이후 try_files 를 통해 요청한 URI 의 파일이 존재하지 않을 경우 /index.html 로 해당 URI 를 리다이렉트하여

SPA 내에서 경로를 처리할 수 있게 도와주는 코드를 작성했다!

 

 

 

 도커 컴포즈 종료 및 재빌드

$ sudo docker-compose down
$ sudo docker-compose up --build -d

도커 컴포즈를 종료시킨 뒤 빌드 변경사항을 반영하여 다시 빌드

 

 

 

코드 반영 결과)

정상적으로 세부 주소로 서빙이 된다!

 

이 코드를 구현함으로써 대화가 종료된 이후 다시 초기 채팅방으로 돌아갈 수 있는 구조를 생성할 수 있게 되었다!

 

 

 

 

 

▼ 사이트 방문하여 확인하기 ▼

 

Random-Chat

 

www.random-chat.site

728x90

 

 

 

 

 

- TIL -

문제 발생)

서버 피씨를 실행시키면 Docker Compose 가 자동으로 실행되게끔 설정되어 있는데 아래와 같이 nginx 의 도커 컨테이너가 먼저 실행되어 포트의 충돌이 일어나는 현상이 발생되었다.

 

 

 

문제 해결)

 

nginx 도커 종료 및 재실행 disable 처리

더보기

1) nginx 도커 종료

$ sudo systemctl stop nginx

 

 

 

2) nginx 도커 상태를 확인하여 inactive (dead) 로 작동 중지 확인

$ sudo systemctl status nginx
도커 종료 확인

 

 

 

 3) nginx 도커 재실행 disable 처리

$ sudo systemctl disable nginx

 

 

 

 4) 서버 피씨 재부팅 후 80포트 점유 중인 프로세스 확인

$ sudo lsof -i :80
$ sudo docker ps

80포트 점유중인 프로세스 확인, 활성화된 docker 컨테이너 확인

정상적으로 docker compose 를 통해 nginx 프로세스가 실행된 것을 확인

 

728x90

 

 

 

 

 

라스베가스의 벨라지오 호텔의 유명한 쇼인 태양의 서커스 O show 를 관람하러 들렀다. 무대의 규모에 넋을 잃고 어린 아이처럼 쇼를 관람했다.

무대 중 사진이나 동영상 촬영은 금지였지만 무대가 끝난 후 인사할때는 촬영이 가능해서 기뻤다...! 조금이나마 무대의 웅장함을 담은... 듯...?

 

 

 

 

 

- TIL -

구현 목표)

SSL 인증서를 발급하여 www.random-chat.site 경로와 api.random-chat.site 경로를 각각 443 포트를 사용해 https 요청을 받도록 적용할 것이다.

이때 지금 구현한 http 경로로 접근하는 요청은 전부 443 포트로 매핑하여 경로를 우회하여 접근되게끔 설정할 것이다.

 

 

 

 

 

SSL 인증서 발급을 위해 Cerbot 설치 및 인증서 발급)

더보기

1) apt update 진행

$ sudo apt-get update
$ sudo apt-get upgrade

 

 

 

2) SSL 발급을 위해 cerbot 을 설치

$ sudo apt-get install python3-certbot-nginx

Cerbot 은 Let's Encrypt 인증서를 사용하여 HTTPS 를 사용할 수 있게 해주는 오픈소스 툴이다.

 

 

 

3) nginx 컨테이너 동작 중지

$ sudo docker stop $(sudo docker ps -q)

 포트 충돌이 날 수 있기 때문에 잠시 올려뒀던 모든 컨테이너를 중지

 

 

 

4) 컨테이너를 중지시킨 후에도 자꾸 80포트를 점유중이라는 문구가 나와 80포트에 대한 모든 프로세스를 종료 후 5번 실행

$ sudo fuser -k 80/tcp

 5) 를 먼저 시도 후 정상적으로 SSL 인증서가 발급되었다면 4) 는 실행할 필요가 없다.

 

 

 

5) 설치한 cerbot 툴을 사용해서 SSL 인증서를 발급

$ sudo certbot certonly --standalone -d www.random-chat.site -d api.random-chat.site

 certonly 옵션을 적용하지 않으면 SSL 인증서를 발급하면서 nginx 설정까지 자동으로 해버리는데 현재 Docker 이미지를 사용해서 구현하는

입장에서 nginx.conf 설정과 docker-compose.yml 설정을 수동으로 진행해야 하는 부분이 있어서 설정은 수동으로 진행하도록 설치하였다.

 

-d 옵션을 사용해서 인증서를 발급받을 도메인 정보를 각각 기입해줬다.

 

--standalone 을 사용해서 Cerbot 이 자체적으로 간단한 웹 사이트를 작동시켜 해당 도메인의 소유권을 확인한 뒤 인증서를 발급한다.

 

인증서 발급 중 이메일을 기재하고 약관에 동의한다고 y 키를 눌러 승인해주면 된다

 

 

 

6) 인증서가 정상적으로 발급되었는지 확인

/etc/letsencrypt/live/www.random-chat.site 경로에 www.random-chat.site 도메인의 인증서와 api.random-chat.site 도메인의

인증서가 합쳐진 인증서 파일이 생성되었다.

 

 

 

7) 인증서가 정상적으로 두 도메인 모두 발급되었는지 확인

$ sudo openssl x509 -in /etc/letsencrypt/live/www.random-chat.site/fullchain.pem -text | grep DNS

 발급된 fullchain.pem 인증서의 텍스트 중 DNS 문자열을 가지고 있는 내용을 출력

인증서가 정상적으로 발급된 모습

 

 

 

 8) Docker Compose 재실행

$ sudo docker-compose up -d --build

Docker Compose 를 재실행, 이제부턴 발급된 인증서를 설정하기만 하면 되기 때문에 Docker Compose 를 다시 올려준다.

 

 

 

Nginx 설정)

더보기

1) 설치된 인증서 디렉토리를 nginx 설정 디렉토리쪽으로 이동

nginx 도커 설정파일이 있는 쪽으로 인증서 디렉토리 전체 이동

 

 

 

2) Dockerfile 설정

$ sudo vi Dockerfile

 vi 편집기로 Dockerfile 을 열어서 수정

From nginx:latest
COPY nginx.conf /etc/nginx/nginx.conf
COPY ./letsencrypt /etc/letsencrypt

 letsencrypt 디렉토리를 도커 컨테이너 내의 /etc/ 디렉토리로 카피하여 사용

 

 

 

3) nginx.conf 설정파일 수정

기존 nginx.conf > 변경 후 nginx.conf

 1) 80 포트인 http 를 통해 접속되는 도메인을 https 경로로 우회

 2) 443 포트(https)로 접속되는 www.random-chat.site 요청을 ssl 로 처리

 3) 443 포트(https)로 접속되는 api.random-chat.site 요청을 ssl 로 처리

 

ssl_certificate, ssl_certificate_key 는 인증서와 인증서 키가 있는 위치를 설정

 ssl_protocols, ssl_ciphers 는 지원할 SSL/TLS 프로토콜을 명시, 암호와 알고리즘 우선순위 설정

 

 proxy_set_header X-Forwarded-Proto $scheme 은 클라이언트의 원래 요청이 HTTPS로 들어왔는지, HTTP로 들어왔는지 백엔드 서버가 알 수 있도록 하기 위해 사용

 

 

 

docker-compose.yml 설정)

더보기
version: "3.8"
services:
  mysql:
    image: mysql:8.0
    container_name: randomchat-mysql
    restart: always
    environment:
      MYSQL_ROOT_PASSWORD: "yy29623358"
      MYSQL_DATABASE: "randomchat"
      MYSQL_USER: "youngho3358"
      MYSQL_PASSWORD: "yy29623358"
    ports:
      - "3306:3306"
    volumes:
      - mysql_data:/var/lib/mysql

  spring:
    build:
      context: ./spring
    container_name: randomchat-spring
    restart: always
    environment:
      SPRING_DATASOURCE_URL: jdbc:mysql://randomchat-mysql:3306/randomchat
      SPRING_DATASOURCE_USERNAME: youngho3358
      SPRING_DATASOURCE_PASSWORD: yy29623358
    ports:
      - "8080:8080"
    depends_on:
      - mysql

  react:
    build:
      context: ./react
    container_name: randomchat-react
    restart: always
    volumes:
      - ./react/build:/usr/share/nginx/html
    ports:
      - "3000:80"

  nginx:
    build:
      context: ./nginx
    container_name: randomchat-nginx
    restart: always
    ports:
      - "80:80"
      - "443:443"
    depends_on:
      - spring
      - react
    volumes:
      - ./nginx/letsencrypt:/etc/letsencrypt

volumes:
  mysql_data:

nginx 설정 부분에 443 포트를 매핑, volumes 로 로컬의 randomchat/nginx/letsencrypt 경로를 도커 컨테이너 내의 /etc/letsencrypt 로 연동

 

즉, 로컬의 randomchat/nginx/letsencrypt 의 파일 정보가 변경되면 도커 컨테이너의 /etc/letsencrypt 가 자동으로 변경된다는 의미이다.

 위 설정을 한 이유는 letsencrypt 의 경우 90일의 무료 인증서 기간을 제공하는데 추후 crontab 기능으로 90일 마다 인증서를 갱신하게끔

설정하여 도커 컨테이너의 인증서 파일을 90일 기준으로 갱신처리하기 위함이다.

 

 

 

도커 컴포즈 재빌드)

더보기

1) 전체 빌드 후 재시작

$ sudo docker-compose up --build

 

 

 

 2) 전체 빌드 후 백그라운드에서 실행 (-d 옵션)

$ sudo docker-compose up --build -d

 

 

 

3) 기존 컨테이너 정리 후 빌드

$ sudo docker-compose down && docker-compose up --build

 

 

 

 4) 캐시를 무시하고 빌드

$ sudo docker-compose build --no-cache && sudo docker-compose up -d

( 빌드 후에도 변경사항이 적용되지 않는 경우 실행 - 이전 캐시 정보로 계속 빌드하는 경우 )

 

 

 

 

 

 

 

위 빌드 방법 중 나는 캐시정보 때문인지 네트워크 정보 때문인지 빌드가 되지 않아 싹 날려버리고 아래와 같이 진행했다.

$ sudo docker-compose down --volumes --remove-orphans
$ sudo docker network prune -f
$ sudo docker volume prune -f

$ sudo docker-compose up --build -d

모든 컨테이너 볼륨, 네트워크, 캐시를 제거한 뒤 재빌드

 

 

 

 

 

 

문제 발생 및 해결)

 

인증서 권한문제 해결)

더보기

도커 컨테이너를 재실행 했을때 nginx 컨테이너가 계속 restarting 으로 표시되는 것을 확인....

2024/12/22 13:27:55 [emerg] 1#1: cannot load certificate "/etc/letsencrypt/live/www.random-chat.site/fullchain.pem": BIO_new_file() failed (SSL: error:80000002:system library::No such file or directory:calling fopen(/etc/letsencrypt/live/www.random-chat.site/fullchain.pem, r) error:10000080:BIO routines::no such file)
nginx: [emerg] cannot load certificate "/etc/letsencrypt/live/www.random-chat.site/fullchain.pem": BIO_new_file() failed (SSL: error:80000002:system library::No such file or directory:calling fopen(/etc/letsencrypt/live/www.random-chat.site/fullchain.pem, r) error:10000080:BIO routines::no such file)

 위와 같은 에러 로그가 남는 것을 확인하였다.

파일을 확인하지 못하는 이유는 실제 인증서 파일이 없거나 혹은 권한이 없기 때문이라고 한다.

 

나는 모든 코드를 sudo 를 사용해 관리자 권한으로 실행했으므로 nginx 에서 COPY 를 통해 파일을 도커 컨테이너로 복사해가도

root 권한이 아닌 이상 읽지 못하는 상태였을 것이다.

 

 

 

 1) letsencrypt 디렉토리의 권한 변경

$ sudo chown -R youngho3358:youngho3358 ./letsencrypt
$ sudo chmod -R 755 ./letsencrypt

사용자를 root 가 아닌 youngho3358 로 변경 한 뒤 letsencrypt 디렉토리를 모든 사용자에게 읽기, 실행 권한을 부여한 뒤 다시 빌드

 

 

 

 

 

 

권한 문제일거라 생각했지만... 결국 코드를 천천히 살펴보니 docker-compose.yml 파일에 nginx volumes 에

./nginx/letsencrypt:/etc/letsencrypt 로 기재해야 할 부분을 .nginx/letsencrypt:/etc/letsencrypt 로 기재해서 발생했던 문제였다.

 

겨우 / 하나 빠진걸로 시간을 3시간이나 소비했지만 결과적으로는 권한 문제로도 해당 에러가 발생할 수 있음을 인지하고 해당 디렉토리의

권한을 755 로 유지하기로 하였다.

 

 

 

 

 

front api 요청 경로 및 websocket 연결 경로를 https 기준에 맞게 변경)

기존에 https:// 와 ws:// 형태를 사용하면 https 쪽으로 이동되기 때문에 https 와 wss 를 적용해서 요청을 보내야 한다.

 

 

 

 

 

프론트 요청 주소를 변경하고 다시 빌드하여 사이트를 재배포하였습니다.

 

Random-Chat

 

www.random-chat.site

728x90

+ Recent posts