241112 파일 업로드 (DB) / Security

241112 에이콘 아카데미 수업을 기반하여 작성되었음을 알립니다.

파일 업로드 (DB) / Security

DB에 파일을 업로드하게 된다면 어디서든 불러다 쓸 수 있다는 장점이있다.
단점으로는 이미지나 사운드의 양이 엄청 많다면 DB가 무거워진다는 단점이 있다. 백업과 복구가 힘들다!
이미지의 경우에 있어서는 DB에 집어넣는 방법이 더 좋지만 사운드의 경우는 무겁기 때문에 비추천된다.

clob, blob 타입 알아보기!

DB에서 이미지 분류/분석, base64로 인코딩된 이미지 패턴을 DB에 집어넣어, DB는 패턴을 파악하여 어떤 이미지인지 판단함!

API를 읽어보는 것이 중요하다!! 우리는 이미 만들어져있는 라이브러리를 가져다가 프로젝트를 만드는 것!

시큐리티, CSRF 공격

파일 업로드 실습 (DB)

테이블 생성

create table friend(
bunho int primary key,
irum varchar(20) not null,
junhwa varchar(20),
jikup varchar(50),
sajin longblob,
imagetype varchar(255));

insert into friend(bunho,irum,junhwa,jikup) values(1,'가나다','010-1234-5678','자바개발자');

dependencies

application.properties

spring.application.name=sprweb43friend

server.port=80
spring.thymeleaf.cache=false

# mariadb server connect
spring.datasource.driver-class-name=org.mariadb.jdbc.Driver
spring.datasource.url=jdbc:mariadb://127.0.0.1:3306/memberdb
spring.datasource.username=root
spring.datasource.password=1111
# jpa
spring.jpa.properties.hibernate.show_sql=true
spring.jpa.properties.hibernate.format_sql=true
spring.jpa.properties.hibernate.use_sql_comments=true
logging.level.org.hibernate.type.descriptor.sql=trace
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MariaDBDialect

# upload directory
file.upload-dir=./uploads

WebController

package pack.controller;

import java.util.List;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;

import pack.model.Friend;
import pack.model.FriendService;

@Controller
public class WebController {
	@Autowired
	private FriendService friendService;
	
	@GetMapping("/")
	public String showStart() {
		return "start";
	}
	
	@GetMapping("/list")
	public String showList(Model model) {
		List<Friend> friends = friendService.findAll();
		model.addAttribute("friends", friends);
		return "list";
	}
}

UploadController

package pack.controller;

import org.springframework.beans.factory.annotation.Autowired;
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;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;

import pack.model.Friend;
import pack.model.FriendService;

@Controller
public class UploadController {
	@Autowired
	private FriendService friendService;
	
	@GetMapping("/insert")
	public String showInsertForm() {
		return "insert";
	}
	
	@PostMapping("/insert")
	public String insertProcess(
			@RequestParam("irum") String irum,
			@RequestParam("junhwa") String junhwa,
			@RequestParam("jikup") String jikup,
			@RequestParam("file") MultipartFile file,
			RedirectAttributes redirectAttributes) {
		// RedirectAttributes : 리디렉션을 수행할 때 한 컨트롤러 메서드에서 다른 컨트롤러 메서드로 Attributes 를 전달하는데 이용
		// addFlashAttribute : 세션에 저장되고 검증 결과 성공 실패 여부 메세지와 같이 임시로 사용되는 데이터를 다루는데 적합하다. 
		// 일회용으로 유지 redirect로 사용자에게 메세지 전달등으로 사용 가능
		// 또 주소 창에 표기되지 않으므로 addAttribute() 보다 폐쇄적이다.
		
		// 파일 크기 제한 (2MB 미만)
		if(!file.isEmpty() && file.getSize() > 2097152) {
			redirectAttributes.addFlashAttribute("message", "파일 크기가 너무 크다");
			return "redirect:/insert";
		}
		// file.getContentType() : MultipartFile에 포함된 메소드. 업로드된 MIME 타입을 반환하는데 사용 
		if(!file.getContentType().startsWith("image/")) { // 이미지 파일인지를 확인
			redirectAttributes.addFlashAttribute("message", "이미지 파일이 아니야");
			return "redirect:/insert";
		}
		
		// insert 처리 출발
		try {
			Friend friend = new Friend();
			friend.setBunho(friendService.generateBunho());
			friend.setIrum(irum);
			friend.setJunhwa(junhwa);
			friend.setJikup(jikup);
			friend.setSajin(file.getBytes());
			friend.setImagetype(file.getContentType());
			friendService.saveFriend(friend);
		} catch (Exception e) {
			redirectAttributes.addFlashAttribute("message", "파일 저장 도중 에러 발생");
			return "redirect:/insert";
		}
		
		return "redirect:/list";
	}
}

Entity

package pack.model;

import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Transient; // import 주의
import lombok.Getter;
import lombok.Setter;

@Entity
@Getter
@Setter
public class Friend {
	@Id
	private int bunho;
	
	private String irum;
	private String junhwa;
	private String jikup;
	
	@Lob // @Lob : 데이터베이스 BLOB, CLOB 타입과 매핑
	private byte[] sajin;
	
	private String imagetype;
	
	@Transient // 임시로 담아두는 역할, DB의 물리적인 테이블과 연관되는 것은 아님!
	private String base64Image;
}

Repository

package pack.model;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;

public interface FriendRepository extends JpaRepository<Friend, Integer> {
	// bunho는 프로그램으로 증가
	@Query("select max(f.bunho) from Friend f")
	Integer findLastBunho();
}

Service

package pack.model;

import java.util.Base64;
import java.util.List;
import java.util.stream.Collectors;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

@Service
public class FriendService {
	@Autowired
	private FriendRepository friendRepository;
	
	public void saveFriend(Friend friend) {
		friendRepository.save(friend);
	}
	
	public List<Friend> findAll() {
		return friendRepository.findAll()
				.stream()
				.map(this::convertToBase64)
				.collect(Collectors.toList());
	}
	
	private Friend convertToBase64(Friend friend) {
		if(friend.getSajin() != null && friend.getSajin().length > 0) {
			// friend image를 Base64로 인코딩하여 Friend 객체에 저장
			// encodeToString byte로 하는 것보다 길이가 좀더 짧아짐
			String base64Image = Base64.getEncoder().encodeToString(friend.getSajin());
			System.out.println(base64Image);
			friend.setBase64Image(base64Image);
		}
		return friend;
	}
	
	// 친구 추가시 번호 구하기
	public int generateBunho() {
		Integer lastBunho = friendRepository.findLastBunho();
		
		if(lastBunho == null) {
			return lastBunho = 1;
		} else {
			return lastBunho + 1;
		}
	}
}

start

<!DOCTYPE html>
<html xmlns:th="https://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Insert title here</title>
</head>
<body>
<h2>메인 페이지</h2>
<a th:href="@{/list}">친구 정보 보기</a>
</body>
</html>

list

<!DOCTYPE html>
<html xmlns:th="https://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Insert title here</title>
</head>
<body>
<h2>* 친구 정보 *</h2>
<div><a th:href="@{/insert}">친구 추가</a></div>
<table>
  <tr>
    <th>번호</th><th>이름</th><th>전화</th><th>직업</th><th>사진</th>
  </tr>
  <tr th:each="friend : ${friends}">
    <td th:text="${friend.bunho}">번호</td>
    <td th:text="${friend.irum}">이룸</td>
    <td th:text="${friend.junhwa}">전화</td>
    <td th:text="${friend.jikup}">직업</td>
    <td>
      <img th:src="'data:' + ${friend.imagetype} + ';base64,' + ${friend.base64Image}"
      style="width: 150px; height: auto;" alt="Image"/>
    </td>
  </tr>
</table>
</body>
</html>

insert

<!DOCTYPE html>
<html xmlns:th="https://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Insert title here</title>
</head>
<body>
<h2>친구 등록</h2>
<div th:if="${message}" style="color: red;">
  <p th:text="${message}"></p>
</div>
<form th:action="@{/insert}" method="post" enctype="multipart/form-data">
이름 : <input type="text" name="irum" required><br/>
전화 : <input type="text" name="junhwa" required><br/>
직업 : <input type="text" name="jikup" required><br/>
사진 : <input type="file" name="file" required><br/><br/>
<button type="submit">등록</button>
<button type="button" onclick="history.back()">이전</button>
</form>
</body>
</html>

결과


security

bulid.gradle

dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-security'
}
id: user
Using generated security password: 2f294026-02ac-4892-ad43-efdfd42b7b86

모든 브라우저 종료 후 다시 서버실행하면 콘솔에 스프링에서 기본적으로 제공하는 로그인 화면이 뜬다!

어떤 페이지로 접속해도 login 페이지로 간다!

list

<!DOCTYPE html>
<html xmlns:th="https://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Insert title here</title>
</head>
<body>
<h2>* 친구 정보 *</h2>
<div><a th:href="@{/insert}">친구 추가</a></div>

<div>
  <form th:action="@{/logout}" method="post">
  	<input type="hidden" name="${__csrf.parameterName}" value="${__csrf.token}"/>
  	<button type="submit">로그아웃</button>	
  </form>
</div>
... 생략
</body>
</html>

login

<!DOCTYPE html>
<html xmlns:th="https://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Insert title here</title>
</head>
<body>
<h2>* 로그인 페이지 *</h2>
<form th:action="@{/login}" method="post">
사용자 이름 : <input type="text" id="username" name="username" value="user"><br/>
비밀번호 : <input type="text" id="password" name="password" value=""><br/>
<br/>
<button type="submit"> 확 인 </button>
</form>
</body>
</html>

Controller

package pack.controller;

import java.util.List;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;

import pack.model.Friend;
import pack.model.FriendService;

@Controller
public class WebController {
	@Autowired
	private FriendService friendService;
	
... 생략
	
	// 시큐리티 로그인 기능 연습용
	@GetMapping("login")
	public String showLoginpage() {
		return "login";
	}
}

SecurityConfig

내가 만든 로그인 페이지로 변경하기 위한 환경설정!

package pack.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
@EnableWebSecurity  // 시큐리티 기본 기능 활성화
public class SecurityConfig {
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
        // 명시적으로 Security 구성
        httpSecurity
            .authorizeHttpRequests((requests) -> 
                requests.requestMatchers("/").permitAll() // root 접근 허용
                        .anyRequest().authenticated()
            )
            .formLogin((form) -> 
                form.loginPage("/login") // login 페이지로 이동하게 설정
                    .permitAll()
            )
            .logout((logout) -> 
                logout.logoutUrl("/logout")
                      .logoutSuccessUrl("/")
                      .invalidateHttpSession(true)
                      .deleteCookies("JSESSIONID")
                      .permitAll()
            )
            .csrf((csrf) -> csrf.disable());

        return httpSecurity.build();
    }

    @Bean
    public UserDetailsService userDetailsService() {
        // UserDetailsService : Spring Security에서 유저의 정보를 가져오는 인터페이스이다.
        // 사용자 세부 정보를 관리하는 서비스 정의
        // UserDetails : Spring Security에서 사용자의 정보를 담는 인터페이스이다.
        UserDetails user = User.builder()
                            .username("user")
                            .password(passwordEncoder().encode("123"))
                            .roles("USER")
                            .build();
        return new InMemoryUserDetailsManager(user);
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder(); // BCrypt 비밀번호 인코더를 사용해 빈을 생성
    }
}

login / root url만 접근 가능!

그 의외의 어떤 요청이 들어오든 만들어놓은 로그인 페이지로 이동, 스프링에서 제공한 초기 비밀번호도 달라짐!

결과

 

로그인 성공!