🛠 게시판 만들기 과제 :
LAMP 웹 서버 구축하기 목표 :
1. 해당 웹 서버의 기본적인 문법, 구조, 동작 방식, 사용 방법 등을 이해
2. 추후에 웹 해킹 실습을 하기 위한 웹 서버 구축
세부 명세서 :
1. 리눅스 환경, Apache 웹 서버, Mysql 데이터베이스, PHP 언어를 사용해야 함
2. 다음 기능이 반드시 구현되어야 함
-여러 개시물을 리스팅해주는 기능 (메인화면)
- 게시글을 검색하는 기능 - 게시물을 생성, 삭제, 수정하는 기능
- 게시글에 파일을 업로드하는 기능
- 회원가입 로그인 로그아웃(사용자 식별을 쿠키, 세션으로 해결 함)
3. 외부에서 접속이 가능할 것 (클라우드 사용 권장, 로컬일 경우 포트포워딩을 통해 외부로 접속해야함)
ADVANCED
Dockerfile과 Docker-compose.yml 을 이용하여 mysql, php 서버를 컨테이너로 분리해 가상화하기
소스코드
https://github.com/wogho/Bulletin-Board
프로젝트 구조
1. PHP 파일
index.php | 메인 페이지. 게시물 목록 표시. |
view_post.php | 특정 게시물 상세 보기. 수정/삭제 버튼 포함. |
create_post.php | 게시물 작성 페이지. |
delete_post.php | 게시물 삭제 처리. |
edit_post.php | 게시물 수정 페이지. |
login.php | 사용자 로그인 처리. |
logout.php | 사용자 로그아웃 처리. |
register.php | 사용자 회원가입 처리. |
search.php | 게시물 검색 기능 제공. |
db.php | 데이터베이스 연결 설정. |
2. CSS 파일
style.css | 애플리케이션의 전체 스타일 정의. |
3. Apache 설정 파일
.htaccess | HTTP → HTTPS 리디렉션 및 URL 리라이트 설정. |
default-ssl.conf | SSL 설정이 포함된 Apache 가상 호스트 설정. |
4. 인증서 및 키 파일
/etc/ssl/certs/selfsigned.crt | 자체 서명된 SSL 인증서 파일. |
/etc/ssl/private/selfsigned.key | SSL 인증서의 개인 키 파일. |
5. 데이터베이스
users | 사용자 정보(아이디, 비밀번호 등). |
posts | 게시물 정보(제목, 내용, 작성자 등). |
/var/www/html/
│
├── index.php
├── view_post.php
├── create_post.php
├── delete_post.php
├── edit_post.php
├── login.php
├── logout.php
├── register.php
├── search.php
├── db.php
│
├── style.css
│
├── .htaccess
├── default-ssl.conf
│
├── /etc/ssl/certs/selfsigned.crt
└── /etc/ssl/private/selfsigned.key
1. 패키지 설치
Apache2, MySQL, PHP 설치 하기
apt update
apt install apache2 mysql-server php php-mysql libapache2-mod-php unzip
설치 확인
systemctl status apache2
systemctl status mysql
php -v
2. 데이터 베이스 설계
데이터베이스 진입
mysql -u root -p
데이터베이스 생성
데이터베이스 명 : bulletin_board
CREATE DATABASE bulletin_board;
데이터베이스 생성확인
SHOW DATABASES;
데이터베이스 사용
USE bulletin_board;
데이터베이스 테이블 생성
users 테이블: 회원 관리
CREATE TABLE users (
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(50) NOT NULL,
password VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
posts 테이블: 게시글 관리
CREATE TABLE posts (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
title VARCHAR(255) NOT NULL,
content TEXT NOT NULL,
file_path VARCHAR(255),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
);
PHP로 기능 구현
보안 강화 사항은 추가 작성
항상 반영후에는 재시작 합시다.
systemctl restart apache2
systemctl restart php8.1-fpm.service
index.php 작성
<?php
// 데이터베이스 연결 및 세션 시작
require 'db.php';
session_start();
// 로그아웃 처리
if (isset($_GET['logout'])) {
session_destroy();
header("Location: index.php");
exit();
}
// 현재 로그인 상태 확인
$is_logged_in = isset($_SESSION['user_id']);
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Bulletin Board</title>
<link rel="stylesheet" type="text/css" href="style.css">
</head>
<body>
<header>
<h1>Welcome to the Bulletin Board!</h1>
</header>
<div class="container">
<!-- 사용자 인증 영역 -->
<div class="auth">
<?php if ($is_logged_in): ?>
<p>환영합니다, 사용자님! <a href="?logout=true">로그아웃</a></p>
<?php else: ?>
<p>
<a href="register.php">회원가입</a> |
<a href="login.php">로그인</a>
</p>
<?php endif; ?>
</div>
<!-- 검색 폼 -->
<form method="GET" action="search.php">
<input type="text" name="query" placeholder="게시글 검색" required>
<button type="submit">검색</button>
</form>
<!-- 게시글 생성 버튼 -->
<?php if ($is_logged_in): ?>
<form method="POST" action="create_post.php" enctype="multipart/form-data" class="create-form">
<input type="text" name="title" placeholder="제목" required>
<textarea name="content" placeholder="내용" required></textarea>
<input type="file" name="file">
<button type="submit">게시글 작성</button>
</form>
<?php endif; ?>
<!-- 게시글 목록 -->
<ul class="post-list">
<?php
$result = $conn->query("SELECT * FROM posts ORDER BY created_at DESC");
while ($row = $result->fetch_assoc()):
?>
<li>
<h3><a href="view_post.php?id=<?= $row['id'] ?>"><?= htmlspecialchars($row['title']) ?></a></h3>
<p><?= htmlspecialchars($row['content']) ?></p>
<small>작성일: <?= $row['created_at'] ?></small>
<?php if ($is_logged_in && $row['user_id'] == $_SESSION['user_id']): ?>
<!-- 수정 및 삭제 버튼 -->
<form method="POST" action="edit_post.php">
<input type="hidden" name="id" value="<?= $row['id'] ?>">
<button type="submit">수정</button>
</form>
<form method="POST" action="delete_post.php" onsubmit="return confirm('게시글을 삭제하시겠습니까?');">
<input type="hidden" name="id" value="<?= $row['id'] ?>">
<button type="submit">삭제</button>
</form>
<?php endif; ?>
</li>
<?php endwhile; ?>
</ul>
</div>
<footer>
<p>© 2024 Bulletin Board. All rights reserved.</p>
</footer>
</body>
</html>
<?php
require 'db.php';
session_start();
// 로그아웃 처리
if (isset($_GET['logout'])) {
session_destroy();
header("Location: index.php");
exit();
}
// 로그인 상태 확인
$is_logged_in = isset($_SESSION['user_id']);
?>
- db.php를 불러와 데이터베이스 연결을 초기화합니다. (추후 생성)
- session_start()를 호출하여 세션을 시작합니다. 이를 통해 사용자의 로그인 상태를 확인하거나 로그아웃 처리 시 활용합니다.
- 로그아웃 기능: ?logout=true 요청을 받으면 세션을 종료하고 페이지를 다시 로드합니다.
사용자 인증 영역
<div class="auth">
<?php if ($is_logged_in): ?>
<p>환영합니다, 사용자님! <a href="?logout=true">로그아웃</a></p>
<?php else: ?>
<p>
<a href="register.php">회원가입</a> |
<a href="login.php">로그인</a>
</p>
<?php endif; ?>
</div>
- 로그인 여부에 따라 다른 UI를 제공합니다.
- 로그인된 경우: 환영 메시지와 로그아웃 링크 표시.
- 로그인되지 않은 경우: 회원가입 및 로그인 링크 표시.
게시글 검색
<form method="GET" action="search.php">
<input type="text" name="query" placeholder="게시글 검색" required>
<button type="submit">검색</button>
</form>
- 입력한 검색어를 search.php로 전달하여 검색 결과를 출력합니다.
- search.php는 SQL LIKE 연산자를 활용해 검색 기능을 처리합니다.
게시글 목록 출력
<ul class="post-list">
<?php
$result = $conn->query("SELECT * FROM posts ORDER BY created_at DESC");
while ($row = $result->fetch_assoc()):
?>
<li>
<h3><a href="view_post.php?id=<?= $row['id'] ?>"><?= htmlspecialchars($row['title']) ?></a></h3>
<p><?= htmlspecialchars($row['content']) ?></p>
<small>작성일: <?= $row['created_at'] ?></small>
<?php if ($is_logged_in && $row['user_id'] == $_SESSION['user_id']): ?>
<form method="POST" action="edit_post.php">
<input type="hidden" name="id" value="<?= $row['id'] ?>">
<button type="submit">수정</button>
</form>
<form method="POST" action="delete_post.php" onsubmit="return confirm('게시글을 삭제하시겠습니까?');">
<input type="hidden" name="id" value="<?= $row['id'] ?>">
<button type="submit">삭제</button>
</form>
<?php endif; ?>
</li>
<?php endwhile; ?>
</ul>
- 데이터베이스에서 모든 게시글을 가져와 최신순으로 정렬 후 출력합니다.
- 게시글 작성자가 현재 로그인된 사용자와 동일한 경우 수정 및 삭제 버튼이 표시됩니다.
- HTML 특수 문자를 변환하기 위해 htmlspecialchars를 사용하여 XSS 공격을 방지합니다.
푸터
<footer>
<p>© 2024 Bulletin Board. All rights reserved.</p>
</footer>
- 간단한 저작권 정보를 포함한 푸터를 추가했습니다.
login.php
require 'db.php';
session_start();
- db.php를 통해 MySQL 데이터베이스 연결을 초기화합니다.
- session_start()로 세션을 시작하여 로그인 상태를 유지하거나 확인할 수 있습니다.
POST 요청 확인
if ($_SERVER["REQUEST_METHOD"] == "POST") {
- 로그인 폼에서 제출된 데이터가 POST 요청인지 확인합니다.
- 이 조건이 없으면 GET 요청으로 접근했을 때도 불필요한 로직이 실행될 수 있습니다.
사용자 입력 데이터 수신
$username = $_POST['username'];
$password = $_POST['password'];
- $_POST 배열을 통해 로그인 폼에서 제출된 username과 password를 받습니다.
- 데이터 검증이 없는 상태이므로 추가적인 입력 검증이 필요합니다.
데이터베이스 쿼리 실행
$stmt = $conn->prepare("SELECT id, password FROM users WHERE username = ?");
$stmt->bind_param("s", $username);
$stmt->execute();
$stmt->store_result();
- Prepared Statement를 사용하여 SQL Injection 공격을 방지합니다.
- ?는 매개변수로 사용되며, 사용자 입력값은 안전하게 처리됩니다.
- store_result()로 결과를 메모리에 저장해 쿼리 결과의 행 개수를 확인합니다.
사용자 존재 여부 확인
if ($stmt->num_rows > 0) {
$stmt->bind_result($id, $hashed_password);
$stmt->fetch();
- 사용자가 존재하면 데이터베이스에서 가져온 id와 password를 변수에 바인딩합니다.
- 비밀번호 검증을 위한 해시 값은 이후 단계에서 사용됩니다.
비밀번호 검증
if (password_verify($password, $hashed_password)) {
$_SESSION['user_id'] = $id;
echo "로그인 성공!";
header("Location: index.php");
exit();
} else {
echo "비밀번호가 틀립니다.";
}
- password_verify()를 사용하여 입력된 비밀번호를 데이터베이스에 저장된 해시된 비밀번호와 비교합니다.
- 검증 성공 시:
- $_SESSION['user_id']에 사용자 ID를 저장하여 로그인 상태를 유지.
- 메인 페이지로 리다이렉트.
- 검증 실패 시:
- 에러 메시지 출력: "비밀번호가 틀립니다."
사용자 없음 처리
} else {
echo "사용자가 존재하지 않습니다.";
}
데이터베이스에서 사용자를 찾지 못했을 경우 출력되는 메시지.
HTML 로그인 폼
<form method="POST">
<input type="text" name="username" placeholder="아이디" required>
<input type="password" name="password" placeholder="비밀번호" required>
<button type="submit">로그인</button>
</form>
- method="POST"로 데이터를 안전하게 전송.
- required 속성으로 클라이언트 측에서 기본적인 필수 입력 검증.
db.php
<?php
$servername = "127.0.0.1"; //외부 ip 작성
$username = "root";
$password = "root"; //mysql 비밀번호
$dbname = "bulletin_board"; //데이터베이스
// 데이터베이스 연결
$conn = new mysqli($servername, $username, $password, $dbname);
// 연결 확인
if ($conn->connect_error) {
die("Connection failed: " . $conn->connect_error);
}
?>
기본 설정
$servername = "127.0.0.1";
$username = "root";
$password = "root";
$dbname = "bulletin_board";
변수 선언:
- servername: 데이터베이스 서버의 주소. 일반적으로 로컬 서버는 127.0.0.1이나 localhost를 사용합니다.
- username: 데이터베이스 접속 사용자 이름.
- password: 데이터베이스 접속 비밀번호.
- dbname: 연결할 데이터베이스 이름.
데이터베이스 연결
$conn = new mysqli($servername, $username, $password, $dbname);
- new mysqli: MySQL 데이터베이스 연결을 생성합니다.
- 매개변수:
- servername: 데이터베이스 서버의 주소.
- username: 접속 사용자 이름.
- password: 접속 비밀번호.
- dbname: 연결할 데이터베이스 이름.
- 성공적인 연결:
- mysqli 객체가 생성되며 이후의 데이터 작업에서 $conn 변수를 통해 쿼리를 실행합니다.
연결 확인
if ($conn->connect_error) {
die("Connection failed: " . $conn->connect_error);
}
역할:
- 연결이 실패하면 connect_error 속성을 확인하고 프로그램 실행을 중단하며 오류 메시지를 출력합니다.
register.php
<?php
require 'db.php';
if ($_SERVER["REQUEST_METHOD"] == "POST") {
$username = $_POST['username'];
$password = password_hash($_POST['password'], PASSWORD_BCRYPT);
$stmt = $conn->prepare("INSERT INTO users (username, password) VALUES (?, ?)");
$stmt->bind_param("ss", $username, $password);
if ($stmt->execute()) {
echo "회원가입 성공!";
} else {
echo "회원가입 실패: " . $stmt->error;
}
$stmt->close();
}
?>
<form method="POST">
<input type="text" name="username" placeholder="아이디" required>
<input type="password" name="password" placeholder="비밀번호" required>
<button type="submit">회원가입</button>
</form>
데이터베이스 연결
require 'db.php';
- db.php를 불러와 데이터베이스 연결을 초기화합니다.
- 이 연결 객체 $conn을 통해 데이터베이스 작업을 수행합니다.
POST 요청 처리
if ($_SERVER["REQUEST_METHOD"] == "POST") {
- 사용자가 회원가입 폼을 제출했는지 확인합니다.
- POST 요청이 아닌 경우 폼만 렌더링되며 추가 작업은 수행되지 않습니다.
사용자 입력값 수신
$username = $_POST['username'];
$password = password_hash($_POST['password'], PASSWORD_BCRYPT);
- $_POST['username']와 $_POST['password']로 사용자 입력값을 받습니다.
- 비밀번호 해싱:
- password_hash()를 사용하여 비밀번호를 안전하게 암호화합니다.
- PASSWORD_BCRYPT 알고리즘은 소금값(salt)을 자동으로 추가하여 안전성을 높입니다.
데이터베이스 쿼리 준비
$stmt = $conn->prepare("INSERT INTO users (username, password) VALUES (?, ?)");
$stmt->bind_param("ss", $username, $password);
- Prepared Statement를 사용하여 SQL Injection 공격을 방지합니다.
- ?는 플레이스홀더로, bind_param() 메서드를 통해 사용자 입력값을 안전하게 바인딩합니다.
- "ss"는 두 개의 문자열 타입 매개변수를 나타냅니다.
쿼리 실행 및 결과 처리
if ($stmt->execute()) {
echo "회원가입 성공!";
} else {
echo "회원가입 실패: " . $stmt->error;
}
- execute()로 준비된 쿼리를 실행합니다.
- 성공 시 메시지 출력: "회원가입 성공!"
- 실패 시 에러 메시지 출력:
- 중복된 아이디(유니크 제약 위반) 같은 이유로 실패할 수 있습니다.
HTML 폼
<form method="POST">
<input type="text" name="username" placeholder="아이디" required>
<input type="password" name="password" placeholder="비밀번호" required>
<button type="submit">회원가입</button>
</form>
- method="POST"로 데이터를 안전하게 서버로 전송합니다.
- required 속성을 추가하여 클라이언트 측에서 기본적인 유효성 검사를 수행합니다.
search.php
<?php
require 'db.php';
$search_term = "%" . $_GET['query'] . "%";
$stmt = $conn->prepare("SELECT id, title FROM posts WHERE title LIKE ?");
$stmt->bind_param("s", $search_term);
$stmt->execute();
$result = $stmt->get_result();
while ($row = $result->fetch_assoc()) {
echo "<h2><a href='view_post.php?id=" . $row['id'] . "'>" . $row['title'] . "</a></h2>";
}
$stmt->close();
?>
<form method="GET">
<input type="text" name="query" placeholder="검색어 입력" required>
<button type="submit">검색</button>
</form>
데이터베이스 연결
require 'db.php';
- db.php를 불러와 데이터베이스 연결을 초기화합니다.
- 이 연결 객체 $conn을 통해 데이터 작업을 수행합니다.
사용자 입력값 수신
$search_term = "%" . $_GET['query'] . "%";
- 입력값 가져오기:
- $_GET['query']: 사용자가 폼에서 입력한 검색어를 가져옵니다.
- 와일드카드 추가:
- SQL LIKE 연산자로 부분 일치를 검색하기 위해 % 와일드카드를 검색어 앞뒤에 추가합니다.
- 보완 필요 사항:
- $_GET['query']는 사용자 입력값으로 신뢰할 수 없으므로, 유효성을 검증하거나 필터링이 필요합니다.
데이터베이스 쿼리 준비
$stmt = $conn->prepare("SELECT id, title FROM posts WHERE title LIKE ?");
$stmt->bind_param("s", $search_term);
- Prepared Statement:
- LIKE 조건문에 사용자 입력값을 안전하게 바인딩하여 SQL Injection 방지.
- 바인딩된 매개변수:
- "s"는 문자열 타입(search_term)을 나타냅니다.
- 보완 필요 사항:
- 검색 결과가 많을 경우 페이징을 구현해 과도한 결과 출력 방지.
쿼리 실행 및 결과 처리
$stmt->execute();
$result = $stmt->get_result();
- execute(): 쿼리를 실행합니다.
- get_result(): 결과를 가져옵니다.
- 결과는 MySQLi의 결과 객체로 반환됩니다.
while ($row = $result->fetch_assoc()) {
echo "<h2><a href='view_post.php?id=" . $row['id'] . "'>" . $row['title'] . "</a></h2>";
}
- 결과를 반복하여 각 게시글의 제목과 ID를 출력합니다.
- 링크를 통해 view_post.php로 게시글 상세 정보를 확인할 수 있습니다.
- 보완 필요 사항:
- HTML 특수 문자를 이스케이프 처리하여 XSS 방지.
HTML 검색 폼
<form method="GET">
<input type="text" name="query" placeholder="검색어 입력" required>
<button type="submit">검색</button>
</form>
- GET 요청:
- 검색어를 URL 쿼리 문자열로 전달합니다.
- required 속성:
- 클라이언트 측에서 입력값이 없는 경우 폼 제출을 막습니다.
view_post.php
<?php
require 'db.php';
$id = $_GET['id'];
$stmt = $conn->prepare("SELECT posts.title, posts.content, posts.file_path, users.username
FROM posts
JOIN users ON posts.user_id = users.id
WHERE posts.id = ?");
$stmt->bind_param("i", $id);
$stmt->execute();
$stmt->bind_result($title, $content, $file_path, $username);
$stmt->fetch();
echo "<h1>$title</h1>";
echo "<p>작성자: $username</p>";
echo "<p>$content</p>";
if ($file_path) {
echo "<a href='$file_path'>첨부파일 다운로드</a>";
}
$stmt->close();
?>
요청된 게시글 ID 수신
$id = $_GET['id'];
- 사용자 입력값 수신:
- URL의 쿼리 매개변수 id를 통해 게시글 ID를 가져옵니다.
- 보완 필요 사항:
- $_GET['id']는 신뢰할 수 없는 입력값이므로, 반드시 검증 및 필터링이 필요합니다.
데이터베이스 쿼리 준비
$stmt = $conn->prepare("SELECT posts.title, posts.content, posts.file_path, users.username
FROM posts
JOIN users ON posts.user_id = users.id
WHERE posts.id = ?");
$stmt->bind_param("i", $id);
Prepared Statement:
- SQL Injection 공격을 방지하기 위해 Prepared Statement를 사용합니다.
- ?는 플레이스홀더로, 정수형 타입(i)의 게시글 ID를 안전하게 바인딩합니다.
- JOIN 연산:
- users 테이블과 JOIN하여 게시글 작성자의 이름(username)을 가져옵니다.
- 보완 필요 사항:
- 존재하지 않는 게시글에 대한 처리가 필요합니다.
쿼리 실행 및 결과 처리
$stmt->execute();
$stmt->bind_result($title, $content, $file_path, $username);
$stmt->fetch();
- 쿼리 실행:
- execute() 메서드로 쿼리를 실행합니다.
- 결과 바인딩:
- bind_result()로 쿼리 결과를 변수에 저장합니다.
- title, content, file_path, username으로 게시글 정보를 가져옵니다.
게시글 정보 출력
echo "<h1>$title</h1>";
echo "<p>작성자: $username</p>";
echo "<p>$content</p>";
- 게시글의 제목, 작성자, 내용 순으로 출력합니다.
- 보완 필요 사항:
- HTML 특수 문자를 이스케이프 처리하여 XSS 공격을 방지해야 합니다.
첨부 파일 처리
if ($file_path) {
echo "<a href='$file_path'>첨부파일 다운로드</a>";
}
- 첨부파일 확인:
- file_path가 있는 경우, 다운로드 링크를 제공합니다.
- 보완 필요 사항:
- 파일 경로를 외부 사용자에게 노출하지 않도록 해야 합니다.
- basename()을 사용하여 파일명을 제한하거나 별도의 다운로드 스크립트를 사용하는 것이 안전합니다.
create_post.php
<?php
require 'db.php';
session_start();
if (!isset($_SESSION['user_id'])) {
die("로그인이 필요합니다.");
}
if ($_SERVER["REQUEST_METHOD"] == "POST") {
$title = $_POST['title'];
$content = $_POST['content'];
$user_id = $_SESSION['user_id'];
$file_path = null;
if (!empty($_FILES['file']['name'])) {
$target_dir = "uploads/";
$file_path = $target_dir . basename($_FILES["file"]["name"]);
move_uploaded_file($_FILES["file"]["tmp_name"], $file_path);
}
$stmt = $conn->prepare("INSERT INTO posts (user_id, title, content, file_path) VALUES (?, ?, ?, ?)");
$stmt->bind_param("isss", $user_id, $title, $content, $file_path);
if ($stmt->execute()) {
echo "게시글이 작성되었습니다.";
header("Location: index.php");
exit();
} else {
echo "오류 발생: " . $stmt->error;
}
$stmt->close();
}
?>
<form method="POST" enctype="multipart/form-data">
<input type="text" name="title" placeholder="제목" required>
<textarea name="content" placeholder="내용" required></textarea>
<input type="file" name="file">
<button type="submit">게시글 작성</button>
</form>
로그인 확인
if (!isset($_SESSION['user_id'])) {
die("로그인이 필요합니다.");
}
- 세션에 user_id가 존재하지 않으면 로그인 상태가 아니므로 작업을 중단합니다.
- 보완 필요 사항:
- header("Location: login.php")로 로그인 페이지로 리다이렉트하는 것이 사용자 친화적입니다.
POST 요청 처리
if ($_SERVER["REQUEST_METHOD"] == "POST") {
- POST 요청이 제출된 경우에만 폼 데이터를 처리합니다.
- GET 요청일 경우, 폼만 렌더링됩니다.
사용자 입력값 수신
$title = $_POST['title'];
$content = $_POST['content'];
$user_id = $_SESSION['user_id'];
$file_path = null;
- $_POST 배열에서 제목과 내용을 수신합니다.
- $_SESSION['user_id']에서 로그인한 사용자의 ID를 가져옵니다.
파일 업로드 처리
if (!empty($_FILES['file']['name'])) {
$target_dir = "uploads/";
$file_path = $target_dir . basename($_FILES["file"]["name"]);
move_uploaded_file($_FILES["file"]["tmp_name"], $file_path);
}
- 업로드 디렉토리 지정:
- 파일은 uploads/ 디렉토리에 저장됩니다.
- 파일 이동:
- move_uploaded_file()를 사용해 업로드된 파일을 지정된 위치로 이동합니다.
- 보완 필요 사항:
- 허용 파일 형식 및 크기를 검증해야 합니다.
- 파일명이 중복되지 않도록 처리해야 합니다.
데이터베이스 삽입
$stmt = $conn->prepare("INSERT INTO posts (user_id, title, content, file_path) VALUES (?, ?, ?, ?)");
$stmt->bind_param("isss", $user_id, $title, $content, $file_path);
- Prepared Statement:
- SQL Injection 방지를 위해 사용됩니다.
- 바인딩된 매개변수:
- user_id: 정수형(i).
- title, content, file_path: 문자열(s).
성공 및 오류 처리
if ($stmt->execute()) {
echo "게시글이 작성되었습니다.";
header("Location: index.php");
exit();
} else {
echo "오류 발생: " . $stmt->error;
}
- 성공 시:
- 게시글이 작성되었다는 메시지를 출력하고 메인 페이지(index.php)로 리다이렉트합니다.
- 실패 시:
- 오류 메시지를 출력합니다.
HTML 폼
<form method="POST" enctype="multipart/form-data">
<input type="text" name="title" placeholder="제목" required>
<textarea name="content" placeholder="내용" required></textarea>
<input type="file" name="file">
<button type="submit">게시글 작성</button>
</form>
- method="POST":
- 데이터를 안전하게 서버로 전송합니다.
- enctype="multipart/form-data":
- 파일 업로드를 처리하기 위해 필수입니다.
- required 속성:
- 클라이언트 측에서 필수 입력값을 검증합니다.
보안 강화 및 개선 사항
- 보안:
- 모든 사용자 입력값 검증.
- CSRF 방지.
- XSS 방지.
- 파일 업로드 제한 및 실행 방지.
- 브루트포스 방지를 위한 시도 제한.
- db.php 환경변수로 보안 강화
- 개선:
- 에러 메시지 개선.
- 페이징 추가.
- HTTPS 강제 적용.
- 사용자 친화적인 UI 추가.
환경변수를 통한 db.php 연결 정보 보안 강화
vi .env
DB_SERVER= 외부ip
DB_USER=root
DB_PASS= db 비밀번호
DB_NAME=bulletin_board
.env 파일 로드 설정
이 방법은 민감한 정보를 코드에 직접 하드코딩하지 않고 시스템이나 .env 파일로 분리하여 보안을 강화합니다.
phpdotenv 설치
PHP 애플리케이션에서 .env 파일을 읽기 위해 composer를 사용하여 vlucas/phpdotenv 패키지를 설치합니다.
composer require vlucas/phpdotenv
.env 파일 경로 확인
.env 파일이 PHP 코드와 같은 디렉토리에 위치해 있어야 합니다. 현재 경로(/var/www/html/.env)가 적절합니다.
vi db.php
<?php
require_once __DIR__ . '/vendor/autoload.php'; // Composer autoload 로드
use Dotenv\Dotenv;
// .env 파일 로드
$dotenv = Dotenv::createImmutable(__DIR__);
$dotenv->load();
// 환경 변수에서 값 읽기
$servername = $_ENV['DB_SERVER'];
$username = $_ENV['DB_USER'];
$password = $_ENV['DB_PASS'];
$dbname = $_ENV['DB_NAME'];
// 데이터베이스 연결
$conn = new mysqli($servername, $username, $password, $dbname);
// 연결 확인
if ($conn->connect_error) {
error_log("Connection failed: " . $conn->connect_error);
die("데이터베이스 연결 실패. 관리자에게 문의하세요.");
}
?>
github에서 제외
.env 파일을 프로젝트 루트에 두되, .gitignore에 추가하여 Git 저장소에 포함되지 않도록 합니다.
# .gitignore
.env
register.php
보안 사항
- 입력값 검증:
- 아이디와 비밀번호에 대해 정규식 검증을 추가해야 합니다.
$username = trim($_POST['username']);
if (!preg_match('/^[a-zA-Z0-9_]{3,20}$/', $username)) {
die("아이디는 3~20자의 영문, 숫자, 밑줄만 가능합니다.");
}
비밀번호 강도 강화:
- 최소 8자, 대문자, 소문자, 숫자, 특수문자가 포함되도록 요구해야 합니다.
if (!preg_match('/^(?=.*[A-Z])(?=.*[a-z])(?=.*\d)(?=.*[\W_]).{8,}$/', $_POST['password'])) {
die("비밀번호는 최소 8자이며 대문자, 소문자, 숫자, 특수문자를 포함해야 합니다.");
}
개선 사항
-
- 이미 존재하는 아이디로 회원가입을 시도하면 에러를 반환해야 합니다.
- 중복 아이디 확인:
$check_stmt = $conn->prepare("SELECT id FROM users WHERE username = ?");
$check_stmt->bind_param("s", $username);
$check_stmt->execute();
if ($check_stmt->num_rows > 0) {
die("이미 존재하는 아이디입니다.");
}
$check_stmt->close();
CSRF 방지:
- CSRF 토큰을 추가하여 폼 요청의 진위를 검증합니다.
login.php
보안 사항
- 입력값 검증:
- 아이디와 비밀번호에 대해 검증을 추가해야 합니다.
$username = trim($_POST['username']);
if (empty($username) || empty($_POST['password'])) {
die("아이디와 비밀번호를 입력해주세요.");
}
브루트포스 공격 방지:
- 로그인 시도 횟수를 제한합니다.
if (!isset($_SESSION['login_attempts'])) {
$_SESSION['login_attempts'] = 0;
}
if ($_SESSION['login_attempts'] >= 5) {
die("너무 많은 로그인 시도가 있었습니다. 잠시 후 다시 시도해주세요.");
}
개선 사항
- 에러 메시지 개선:
- 사용자가 잘못된 정보를 입력했을 때 세부 정보를 제공하지 않습니다
echo "아이디 또는 비밀번호가 올바르지 않습니다.";
index.php
보안 사항
- XSS 방지:
- 게시글 제목이나 내용을 출력할 때 htmlspecialchars를 사용합니다.
echo "<h1>" . htmlspecialchars($title, ENT_QUOTES) . "</h1>";
개선 사항
-
- 검색 결과가 많을 경우 페이지 단위로 나누어 표시합니다.
- 게시글 검색 결과 페이징:
$stmt = $conn->prepare("SELECT * FROM posts LIMIT ? OFFSET ?");
$stmt->bind_param("ii", $limit, $offset);
.
create_post.php
보안 사항
- 파일 업로드 제한:
- 허용된 파일 형식과 크기를 제한합니다.
$allowed_types = ['image/jpeg', 'image/png', 'application/pdf'];
$max_file_size = 1048576; // 1MB
if (!in_array($_FILES['file']['type'], $allowed_types) || $_FILES['file']['size'] > $max_file_size) {
die("허용되지 않는 파일 형식이거나 파일 크기가 너무 큽니다.");
}
업로드된 파일 실행 방지:
- .htaccess를 사용하여 업로드된 디렉토리에서 PHP 파일 실행을 차단합니다.
php_flag engine off
view_post.php
보안 사항
- XSS 방지:
- 게시글 제목과 내용을 출력할 때 특수 문자를 이스케이프 처리합니다.
echo "<h1>" . htmlspecialchars($title, ENT_QUOTES) . "</h1>";
개선 사항
- 존재하지 않는 게시글 확인:
- 존재하지 않는 게시글에 대한 요청을 처리합니다.
if (!$stmt->fetch()) {
die("게시글을 찾을 수 없습니다.");
}
search.php
보안 사항
- 입력값 검증:
- 검색어를 검증하고 정리합니다.
$query = htmlspecialchars(trim($_GET['query']), ENT_QUOTES);
개선 사항
- 페이징:
- 검색 결과를 페이지 단위로 나누어 표시합니다.
$limit = 10;
$offset = ($page - 1) * $limit;
style.css
개선 사항
- 반응형 디자인:
- 모바일 환경에서도 적절히 동작하도록 반응형 스타일을 추가합니다.
@media (max-width: 768px) {
body {
font-size: 14px;
}
.container {
padding: 10px;
}
}
+ logout.php 누락 되어 생성
<?php
// 세션 시작
session_start();
// 모든 세션 변수 제거
$_SESSION = array();
// 세션 쿠키 제거
if (ini_get("session.use_cookies")) {
$params = session_get_cookie_params();
setcookie(session_name(), '', time() - 42000,
$params["path"], $params["domain"],
$params["secure"], $params["httponly"]
);
}
// 세션 종료
session_destroy();
// 메인 페이지로 리다이렉트
header("Location: index.php");
exit();
?>
+ 게시물 수정, 삭제 기능 누락 되어 생성
view_post.php
<?php
require 'db.php';
session_start();
$id = filter_input(INPUT_GET, 'id', FILTER_VALIDATE_INT);
if ($id === false || $id <= 0) {
die("잘못된 게시글 ID입니다.");
}
$stmt = $conn->prepare("SELECT posts.title, posts.content, posts.file_path, posts.user_id, users.username
FROM posts
JOIN users ON posts.user_id = users.id
WHERE posts.id = ?");
$stmt->bind_param("i", $id);
$stmt->execute();
$stmt->bind_result($title, $content, $file_path, $user_id, $username);
if (!$stmt->fetch()) {
die("게시글을 찾을 수 없습니다.");
}
echo "<h1>" . htmlspecialchars($title, ENT_QUOTES) . "</h1>";
echo "<p>작성자: " . htmlspecialchars($username, ENT_QUOTES) . "</p>";
echo "<p>" . nl2br(htmlspecialchars($content, ENT_QUOTES)) . "</p>";
if ($file_path) {
echo "<a href='" . htmlspecialchars($file_path, ENT_QUOTES) . "'>첨부파일 다운로드</a>";
}
if (isset($_SESSION['user_id']) && $_SESSION['user_id'] == $user_id) {
echo "<form method='POST' action='delete_post.php' style='margin-top: 20px;'>
<input type='hidden' name='id' value='$id'>
<button type='submit'>삭제</button>
</form>";
echo "<a href='edit_post.php?id=$id' style='margin-top: 10px;'>수정</a>";
}
$stmt->close();
?>
delete_post.php
<?php
require 'db.php';
session_start();
if ($_SERVER['REQUEST_METHOD'] == 'POST') {
$id = filter_input(INPUT_POST, 'id', FILTER_VALIDATE_INT);
if ($id === false || $id <= 0) {
die("잘못된 요청입니다.");
}
$stmt = $conn->prepare("SELECT user_id, file_path FROM posts WHERE id = ?");
$stmt->bind_param("i", $id);
$stmt->execute();
$stmt->bind_result($user_id, $file_path);
$stmt->fetch();
$stmt->close();
if (!isset($_SESSION['user_id']) || $_SESSION['user_id'] != $user_id) {
die("권한이 없습니다.");
}
// 첨부파일 삭제
if (!empty($file_path) && file_exists($file_path)) {
unlink($file_path);
}
$stmt = $conn->prepare("DELETE FROM posts WHERE id = ?");
$stmt->bind_param("i", $id);
if ($stmt->execute()) {
header("Location: index.php?status=deleted");
exit();
} else {
die("삭제 실패: " . $stmt->error);
}
$stmt->close();
}
?>
edit_post.php
<?php
require 'db.php';
session_start();
$id = filter_input(INPUT_GET, 'id', FILTER_VALIDATE_INT);
if ($id === false || $id <= 0) {
die("잘못된 요청입니다.");
}
// 게시글 불러오기
$stmt = $conn->prepare("SELECT title, content FROM posts WHERE id = ? AND user_id = ?");
$stmt->bind_param("ii", $id, $_SESSION['user_id']);
$stmt->execute();
$stmt->bind_result($title, $content);
if (!$stmt->fetch()) {
die("게시글을 찾을 수 없거나 수정 권한이 없습니다.");
}
$stmt->close();
if ($_SERVER['REQUEST_METHOD'] == 'POST') {
$new_title = htmlspecialchars($_POST['title'], ENT_QUOTES);
$new_content = htmlspecialchars($_POST['content'], ENT_QUOTES);
$stmt = $conn->prepare("UPDATE posts SET title = ?, content = ? WHERE id = ?");
$stmt->bind_param("ssi", $new_title, $new_content, $id);
if ($stmt->execute()) {
header("Location: view_post.php?id=$id");
exit();
} else {
die("수정 실패: " . $stmt->error);
}
$stmt->close();
}
?>
<form method="POST">
<input type="text" name="title" value="<?= htmlspecialchars($title, ENT_QUOTES) ?>" required>
<textarea name="content" required><?= htmlspecialchars($content, ENT_QUOTES) ?></textarea>
<button type="submit">수정</button>
</form>
style.css 일부 추가
/* 버튼 스타일 추가 */
button, a {
display: inline-block;
background-color: #007BFF;
color: white;
text-decoration: none;
padding: 10px 15px;
border-radius: 4px;
margin: 5px 0;
}
button:hover, a:hover {
background-color: #0056b3;
}