기존에 스프링으로 추가한 것을 스프링 부트로 구현한 것에 검색 세부조건(신상품, Best, 할인)과 정렬조건을 추가로 하였다.

관리자 화면에서 스타일 숍 상품에 대한 검색과 함께 일반 사용자 화면에서 검색 기능을 합하여 구현을 하였다.

github에 소스코드를 다시 push를 하였다.

https://github.com/KYUNGSUB/springboot-lala-market.git

 

GitHub - KYUNGSUB/springboot-lala-market

Contribute to KYUNGSUB/springboot-lala-market development by creating an account on GitHub.

github.com

세부 기능은

- 주 검색 기능

- 세부 검색 기능

- 신상품, Best, 할인 등에 따른 검색

- 정렬조건으로 최신순, 낮은 가격순, 높은 가격순이 있다.

동작 확인은 http://43.200.20.11/product/list이다

물론 관리자로 로그인을 하여야 한다. (user9/!pw09)

로그인하면 관리자 페이지 http://hostname/aindex로 이동 한 후 메뉴 상품 관리->상품 리스트를 선택하면 된다.

Posted by 세상을 살아가는 사람
,

지난 번에 스프링을 이용해서 이정원씨가 지은 "Do it 웹사이트 기획 입문"의 기획서인 라라마켓 쇼핑몰을 일부 구현을 하였고, 블로그에 기술하였다. (https://blog.naver.com/ksseo63/222750784956 ~ https://blog.naver.com/ksseo63/222770023453)

여기서는 스프링 부트를 이용하여 구현해 본다. 같은 기능을 스프링 부트를 이용해서 빨리 구현을 하다보니 기능을 자세히 검증하지 못하고 주 기능만 구현하였다.

구현한 것을 자세히 기술할 필요가 있지만, 남은 기능을 계속 구현할 필요성도 있어서 고민이다. 그래서 우선 여기서 현재까지 구현한 것을 간단하게 기술하고, 앞으로 남은 기능을 계속 추가해 보고자 한다.

우선 개발환경은 다음과 같다.

- Windows 10 Home 또는 Pro OS

- JDK-11.0.16

- STS4 IDE

- MySQL (AWS RDS상에서 동작

구현해야 할 기능에 대한 설계서는 이정원씨가 지은 "Do it 웹 사이트 기획입문"에서 나온 화면정의서(라라마켓_관리자_화면정의서, 라라마켓_이용자_화면저의서)와 여러 기획자료를 참고하였다.

구현한 기능은 다음 순서에 따라 구현하였다. 이것은 스프링으로 개발했던 것을 기반으로 하다보니 같은 순서를 따랐다.

- 라라마켓 레이아웃 추가

- Security 적용

- 관리자 페이지

- 상품 카테고리 관리

- 상품관리 -> 상품등록

- 상품관리 -> 상품 리스트

- 정책관리 -> 정책

- 정책관리 -> 약관관리

- 관리자기능 -> 배너관리

- 회원가입

소스 코드는 github에 등록을 하였다. (https://github.com/KYUNGSUB/springboot-lala-market)

구현된 것을 아마존 클라우드 AWS EC2를 사용하여 동작 시켜 본다.

EC2는 Linux Ubuntu OS를 사용하기 때문에 Windows 환경과는 약간 차이가 있다. 따라서 다음과 같이 수정해 주어야 한다.

github 원격 레포지토리에 있는 소스 코드를 $ git clone https://github.com/KYUNGSUB/springboot-lala-market.git명령어를 입력하여 clone 한다.

$ cd springboot-lala-market을 사용하여 폴더 내로 이동한다.

src/main/resources/application.properties 파일의 설정 정보를 다음과 같이 수정한다.

# server.port=80    # Linux에서 80 포트를 사용하려면 sudo(슈퍼유저) 권한으로 앱을 실행하여야 한다. 따라서 8080 포트를 사용하도록 주석으로 처리해 준다.

spring.servlet.multipart.location=/home/ubuntu/upload

kr.talanton.upload.path=/home/ubuntu/upload

# port
# server.port=80

# 생략된 부분이 있음...
spring.servlet.multipart.enabled=true
#spring.servlet.multipart.location=D:\\zzz\\upload
spring.servlet.multipart.location=/home/ununtu/upload
spring.servlet.multipart.max-request-size=30MB
spring.servlet.multipart.max-file-size=10MB

#kr.talanton.upload.path=D:\\zzz\\upload
kr.talanton.upload.path=/home/ubuntu/upload

# 생략된 부분이 있음...

파일 업로드 시 임시로 생성되는 파일과 파일을 저장할 폴더로 /home/ubuntu/upload를 지정하고 폴더를 생성해 준다.

gradlew 파일에 실행 권한을 부여한다.

$ chmod +x ./gradlew

앱의 실행파일을 생성한다.

$ ./gradlew bootJar./

성공적으로 build가 되고 build/libs 폴더 밑에 springboot-lala-market-0.0.1-SNAPSHOT.jar가 생성됨을 알 수 있다.

생성된 jar 파일을 홈으로 복사한 후 다음 명령어를 사용하여 실행한다.

$ java -jar springboot-lala-market-0.0.1-SNAPSHOT.jar &

AWS EC2는 Ubuntu Linux OS를 사용하므로 Windows OS에서 동작할 때와 약간 차이가 있다. 따라서 일부 수정이 필요하다.

1. basic.html

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<th:block th:fragment="setContent(content)">
        <th:block th:replace="~{./layout/header :: header}">
        </th:block>
    <div class="container-fluid">
        <th:block th:replace = "${content}"></th:block>
    </div>
    <th:block th:replace="~{./layout/footer :: footer}">
    </th:block>
</th:block>
</html>

windows OS에서는 <th:block th:replace="~{/layout/header :: header}"> 라고 하여도 동작하나 "./layout"으로 수정을 해주어야 한다. 이하 다른 파일도 동일하다.

2. HomeController.java

package kr.talanton.lala.main.controller;

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

import lombok.extern.log4j.Log4j2;

@Controller
@Log4j2
public class HomeController {
        @GetMapping("/")
        public String home(Model model) {
                log.info("home...");
                return "home";
        }
}

home() 메소드의 반환형으로 view 페이지를 지정하기 위하여 Windows OS에서는 "/home"을 사용하나, AWS EC2에서는 "home"을 사용하여야 한다. 다른 Controller일 경우도 이와 같이 처리해 주어야 한다.

아래는 브라우저에서 AWS EC2의 주소를 사용하여 접속한 경우이다.

다음과 같이 iptables을 설정하면 80 port 번호로 들어오는 패킷을 8080 포트로 port forwarding할 수 있으며, 외부에서 80 port로 접속할 수 있다.

$ sudo iptables -A PREROUTING -t nat -p tcp --dport 80 -j REDIRECT --to-ports 8080

Posted by 세상을 살아가는 사람
,

김은옥씨가 지은 은노기의 JSP2.3 웹프로그래밍에 나오는 shoppingmall 프로그램을 돌려보기 위한 참고사항을 적어본다.

소스코드는 아래에 첨부한다.

shoppingmall.zip
3.43MB

이 프로그램은 데이터베이스로 MySQL을 사용하였다. 나는 v8.0.13을 사용하였다.

위 소스코드 중 WebContent/WEB-INF/sql/database.sql 파일을 참조한다.

다음과 같이 데이터베이스 및 사용자를 생성하고 권한을 부여해준다. (root 권한으로 실행한다.)

-- 은노기 shoppingmall 프로젝트
create database jsptest;
create user 'jspid'@'localhost' identified by 'jsppass';
create user 'jspid'@'%' identified by 'jsppass';
grant all privileges on jsptest.* to 'jspid'@'localhost';
grant all privileges on jsptest.* to 'jspid'@'%';
commit;

다음과 같이 테이블과 사용자 데이터를 추가한다.

create table member(
  id varchar(50) not null primary key,
  passwd varchar(16) not null,
  name varchar(10) not null,
  reg_date datetime not null
);

alter table member
     add (address varchar(100) not null,
          tel varchar(20) not null);
          
desc member;

insert into member(id, passwd, name, reg_date, address, tel) 
values('kingdora@dragon.com','1234','김개동', now(), '서울시', '010-1111-1111');

insert into member(id, passwd, name, reg_date, address, tel) 
values('hongkd@aaa.com','1111','홍길동', now(), '경기도', '010-2222-2222');

select * from member;

alter table member modify passwd varchar(60) not null;

create table board(
  num int not null primary key auto_increment,
  writer varchar(50) not null,
  subject varchar(50) not null,
  content text not null,
  passwd varchar(60) not null,
  reg_date datetime not null,
  ip varchar(30) not null,
  readcount int default 0,
  ref int not null,
  re_step smallint not null,
  re_level smallint not null
);

desc board;

--쇼핑몰

create table manager(
 managerId varchar(50) not null primary key,
 managerPasswd varchar(60) not null
);

insert into manager(managerId,managerPasswd)
values('bookmaster@shop.com','123456');

insert into manager(managerId,managerPasswd)
values('ksseo63@naver.com','ekffksxm0');

create table book(
  book_id int not null primary key auto_increment,
  book_kind varchar(3) not null,
  book_title varchar(100) not null,
  book_price int not null,
  book_count smallint not null,
  author varchar(40) not null,
  publishing_com varchar(30) not null,
  publishing_date varchar(15) not null,
  book_image varchar(16) default 'nothing.jpg',
  book_content text not null,
  discount_rate tinyint default 10,
  reg_date datetime not null
);

create table qna(
  qna_id int not null primary key auto_increment,
  book_id int not null,
  book_title varchar(100) not null,
  qna_writer varchar(50) not null,
  qna_content text not null,
  group_id int not null,
  qora tinyint not null,
  reply tinyint default 0,
  reg_date datetime not null
);

create table bank(
  account varchar(30) not null,
  bank varchar(10) not null,
  name varchar(10) not null
);

insert into bank(account, bank, name)
values('11111-111-11111','내일은행','오내일');

create table cart(
  cart_id int not null primary key auto_increment,
  buyer varchar(50) not null,
  book_id int not null,
  book_title varchar(100) not null,
  buy_price int not null,
  buy_count tinyint not null,
  book_image varchar(16) default 'nothing.jpg'
);

create table buy(
  buy_id bigint not null,
  buyer varchar(50) not null,
  book_id varchar(12) not null,
  book_title varchar(100) not null,
  buy_price int not null,
  buy_count tinyint not null,
  book_image varchar(16) default 'nothing.jpg',
  buy_date datetime not null,
  account varchar(50) not null,
  deliveryName varchar(10) not null,
  deliveryTel varchar(20) not null,
  deliveryAddress varchar(100) not null,
  sanction varchar(10) default '상품준비중'
);

데이터베이스 연동을 위한 설정은 WebContent/META-INF/context.xml을 참조한다.

<?xml version="1.0" encoding="UTF-8"?>
<Context>
	<Resource auth="Container" driverClassName="com.mysql.jdbc.Driver" maxWait="5000" name="jdbc/eun" password="jsppass" type="javax.sql.DataSource" url="jdbc:mysql://localhost:3306/jsptest?useSSL=false" username="jspid"/>
</Context>

또한 MySQL JDBC Connector 파일도 자신이 사용하는 MySQL Server 버전에 맞추어 변경해 주어야 한다. 이것은 studyjsp와 shoppingmall 프로젝트의 WebContent/WEB-INF/lib 폴더 밑에 있는 JDBC Connecctor 라이브러리에 대하여 맞추어 주어야 한다. 나는 v8.0.13을 사용하므로 mysql-connector-java-8.0.13.jar로 변경해 주었다. (기존에는 5.x.x 버전을 사용)

사용자에 대한 암호를 암호화하기 위해서는 9장에 있는 cryptProcess.jsp를 구동해 주어야 한다. (http://localhost:8080/shoppingmall/ch09/cryptProcess.jsp)

이는 studyjsp 프로젝트 폴더에 있는 소스코드를 shoppingmall 프로젝트 폴더로 복사하여 사용하였다.

은노기의 JSP2.3 웹프로그래밍 9장 375p에 자세한 설명이 되어 있다.

다음은 ch09/cryptProcess.jsp 파일에 대한 내용이다.

<%@page import="ch09.update.UpdateDBBean"%>
<%@ page language="java" contentType="text/html; charset=UTF-8"
    pageEncoding="UTF-8"%>
<%@ page import = "java.util.List" %>
<meta name="viewport" content="width=device-width,initial-scale=1.0"/>
<link rel="stylesheet" href="style.css"/>

<h3>암호화 전 내용</h3>
<jsp:include page="cryptProcessList.jsp" flush="false"/>

<%
  UpdateDBBean dbPro = UpdateDBBean.getInstance();
  dbPro.updateMember();
%>

<h3>암호화가 적용된 후 내용</h3>
<jsp:include page="cryptProcessList.jsp" flush="false"/>

또한 관리자 계정의 비밀번호도 암호화를 수행하여야 하며, 브라우저에서http://localhost:8080/shoppingmall/enc/cryptProcess.jsp를 수행하면 된다.

cryptProcess.jsp에 대한 소스 코드는 아래와 같다.

<%@ page language="java" contentType="text/html; charset=UTF-8"
    pageEncoding="UTF-8"%>
<%@ page import = "java.util.List" %>
<%@ page import = "mngr.enc.PassCrypt" %>
<meta name="viewport" content="width=device-width,initial-scale=1.0"/>

<%
  PassCrypt dbPro = PassCrypt.getInstance();
  dbPro.cryptProcess();
  out.println("암호화 성공! 꼭 한번만 수행");
%>
 

소스코드에 대한 동작을 확인하기 위해서는 관리자로 로그인하여 책에 대한 상품등록을 카테고리별로 3권씩 등록을 해주면 좋을 것 같다. (인터파크 등을 참고하면 쉽게 할 수 있음)

Posted by 세상을 살아가는 사람
,

이전 게시글에서는 로그인 여부에 따른 메뉴 표시와 로그아웃 기능을 구현하였다.(https://talanton.tistory.com/128)

이 글에서는 회원가입 기능을 구현하고자 한다.

- 회원가입 폼을 작성

- 아이디 중복 확인

- 회원가입 절차

초기 동작은 오른쪽 위의 Dropdown 메뉴에서 Join 메뉴를 클릭하면 /member/join이 서버로 요청이 되며, 회원가입 폼이 보여져야 한다.

서버에서는 /member/join url 요청을 처리하기 위하여 MemberController가 동작한다.

	@GetMapping("/join")
	public void join() {
		log.info("join...");
	}

뷰 페이지는 templates/member/join.html이 동작한다.

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<th:block th:replace="~{/layout/basic :: setContent(~{this::content} )}">
    <th:block th:fragment="content">
		<script src="/js/join.js"></script>
		
        <h1 class="mt-4">Join Page</h1>
        <form class="form-join mt-4" method="post" action="/member/join">
        	<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
        	<input type="hidden" name="fromSocial" value="false">
			<div class="form-row">
				<div class="col-lg-10">
					<label>아이디(이메일 형식) *</label>
					<input class="form-control" placeholder="kdhong@naver.com" name="email" type="email" required autofocus>
				</div>
				<div class="col-lg-2">
					<label>* : 필수입력</label>
					<button type="button" class="form-control btn btn-secondary">중복확인</button>
				</div>
			</div>
			<div class="form-row mt-3">
				<div class="col-lg-6">
					<label>암 &nbsp; 호 *</label>
					<input class="form-control"	placeholder="암호 입력 (특수문자, 영문, 숫자의 조합으로 8자 이상 15자 이하)" name="password" type="password" required>
				</div>
				<div class="col-lg-6">
					<label>암호 확인 *</label>
					<input class="form-control" placeholder="암호 입력 (특수문자, 영문, 숫자의 조합으로 8자 이상 15자 이하)" name="password2" type="password" required>
				</div>
			</div>
			<div class="form-row mt-3">
				<div class="col-lg-6">
					<label>이 &nbsp; 름 *</label>
					<input class="form-control"	placeholder="홍길동" name="name" type="text" maxlength="30" required>
				</div>
				<div class="col-lg-6">
					<label>별 &nbsp; 명</label>
					<input class="form-control" placeholder="바람의 아들" name="nickname" maxlength="30" type="text">
				</div>
			</div>
			<div class="form-row mt-3">
				<label>휴대전화</label>
				<input class="form-control" placeholder="010-3333-4444(- 포함)" name="phone" type="tel">
			</div>
			<div class="form-group row mt-3">
				<label for="colFormLabel" class="col-lg-1 col-form-label">생년월일 : </label>
				<div class="col-lg-1">
					<select name="calendar" class="form-control">
						<option value="s" selected>양력</option>
						<option value="l">음력</option>
					</select>
				</div>
				<div class="col-lg-1">
					<input class="form-control"	placeholder="2000" name="year" type="number">
				</div>
				<label class="mt-2">년</label>
				<div class="col-lg-1">
					<input class="form-control"	placeholder="12" name="month" type="number">
				</div>
				<label class="mt-2">월</label>
				<div class="col-lg-1">
					<input class="form-control"	placeholder="12" name="date" type="number">
				</div>
				<label class="mt-2">일</label>
			</div>
			<div class="form-group mt-3">
				<label>주 &nbsp; 소</label> <input class="form-control"
					placeholder="16245 경기도 성남시 수정구 수정로 319번지 753동 4701호" name="address" type="text">
			</div>
			<button type="submit" class="btn btn-success">Join</button>
			<button type="reset" class="btn btn-warning">Reset</button>
        </form>
    </th:block>
</th:block>

부트스트랩을 이전 글에서 사용한 것은 잘 동작하지 않으므로, 기존에 다운로드 받아 사용했던 것을 사용하여 작업을 한다.

startbootstrap-simple-sidebar-gh-pages_old.zip
1.05MB

압축을 해제한 후 src/main/resource/static 폴더에 붙여넣기 한다.

assets 폴더의 아이콘은 재사용 하였다.

css 파일은 simple-sidebar.css로 변경하였다. 기존에 사용하던 styles.css를 삭제를 하였다.

js 폴더는 기존 것은 삭제하고 login.js와 join.js만 유지하였다. 다른 것은 vendor 폴더에 있는 것을 사용한다.

기존의 부트스트랩을 적용하면 basic.html도 변경을 해주어야 한다. 기존에 있던 css와 js 부분을 아래와 같이 변경해 준다.

<title>Talanton 스프링 부트 홈 페이지</title>
<!-- Favicon-->
<link rel="icon" type="image/x-icon" th:href="@{/assets/favicon.ico}" />
<!-- Bootstrap core CSS -->
<link th:href="@{/vendor/bootstrap/css/bootstrap.min.css}" rel="stylesheet">
<!-- Custom styles for this template -->
<link th:href="@{/css/simple-sidebar.css}" rel="stylesheet">
<!-- Bootstrap core JS-->
<script th:src="@{/vendor/jquery/jquery.min.js}"></script>
<script th:src="@{/vendor/bootstrap/js/bootstrap.bundle.min.js}"></script>
</head>

전체 basic.html은 다음과 같이 부트스트랩 template가 변경이 되었으므로 역시 수정해 준다.

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<th:block th:fragment="setContent(content)">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
    <meta name="description" content="">
    <meta name="author" content="">
    <meta name="_csrf" th:content="${_csrf.token}"/>
	<meta name="_csrf_header" th:content="${_csrf.headerName}"/>
    <title>Simple Sidebar - Start Bootstrap Template</title>
    <!-- Bootstrap core CSS -->
    <link th:href="@{/vendor/bootstrap/css/bootstrap.min.css}" rel="stylesheet">
    <!-- Custom styles for this template -->
    <link th:href="@{/css/simple-sidebar.css}" rel="stylesheet">
    <!-- Bootstrap core JavaScript -->
    <script th:src="@{/vendor/jquery/jquery.min.js}"></script>
    <script th:src="@{/vendor/bootstrap/js/bootstrap.bundle.min.js}"></script>
</head>
<body>
<div class="d-flex" id="wrapper">
    <!-- Sidebar -->
    <div class="bg-light border-right" id="sidebar-wrapper">
        <div class="sidebar-heading">Start Bootstrap </div>
        <div class="list-group list-group-flush">
            <a href="#" class="list-group-item list-group-item-action bg-light">Dashboard</a>
            <a href="#" class="list-group-item list-group-item-action bg-light">Shortcuts</a>
            <a href="#" class="list-group-item list-group-item-action bg-light">Overview</a>
            <a href="#" class="list-group-item list-group-item-action bg-light">Events</a>
            <a href="#" class="list-group-item list-group-item-action bg-light">Profile</a>
            <a th:href="@{/sise/list}" class="list-group-item list-group-item-action bg-light">주식 시세 보기</a>
        </div>
    </div>
    <!-- /#sidebar-wrapper -->
    <!-- Page Content -->
    <div id="page-content-wrapper">
        <nav class="navbar navbar-expand-lg navbar-light bg-light border-bottom">
            <button class="btn btn-primary" id="menu-toggle">Toggle Menu</button>
            <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
                <span class="navbar-toggler-icon"></span>
            </button>
            <div class="collapse navbar-collapse" id="navbarSupportedContent">
                <ul class="navbar-nav ml-auto mt-2 mt-lg-0">
                    <li class="nav-item active">
                        <a class="nav-link" th:href="@{/}">Home <span class="sr-only">(current)</span></a>
                    </li>
                    <li class="nav-item">
                        <a class="nav-link" href="#">Link</a>
                    </li>
                    <li class="nav-item dropdown">
                        <a class="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
                            Dropdown
                        </a>
                        <div class="dropdown-menu dropdown-menu-right" aria-labelledby="navbarDropdown">
                            <a sec:authorize="isAuthenticated()" class="dropdown-item logoutBtn" href="#">Logout</a>
                            <a sec:authorize="isAuthenticated()" class="dropdown-item" th:href="@{/member/profile(email=${#authentication.principal.member.email})}">MyPage</a>
                            <a sec:authorize="isAnonymous()" class="dropdown-item" href="/member/login">Login</a>
                            <a sec:authorize="isAnonymous()" class="dropdown-item" href="/member/join">Join</a>
                            <a sec:authorize="isAnonymous()" class="dropdown-item" href="#">아이디 찾기</a>
                            <a sec:authorize="isAnonymous()" class="dropdown-item" href="#">비밀번호 찾기</a>
                        </div>
                    </li>
                </ul>
            </div>
        </nav>
        <div class="container-fluid">
            <th:block th:replace = "${content}"></th:block>
        </div>
    </div>
    <!-- /#page-content-wrapper -->
</div>
<!-- /#wrapper -->
<!-- Menu Toggle Script -->
<script>
	var token = $("meta[name='_csrf']").attr("content");
	var header = $("meta[name='_csrf_header']").attr("content");
	
    $("#menu-toggle").click(function(e) {
        e.preventDefault();
        $("#wrapper").toggleClass("toggled");
    });
    $(".logoutBtn").click(function(e) {
    	e.preventDefault();
    	$.ajax({
    		url: '/member/logout',
    		type: 'post',
    		// CSRF 토큰 값을 헤더로 전송
			beforeSend: function(xhr) {
			    xhr.setRequestHeader(header, token);
			},
			success: function(data) {
				alert("로그아웃 되었습니다.");
				location.href = "/member/login";
			}
    	});
    });
</script>
</body>
</th:block>
</html>

그러면 회원가입 폼은 아래와 같이 표시된다.

- 아이디는 이메일을 사용한다.

- 아이디를 입력하고 중복확인 버튼을 눌러 아이디 중복 확인을 한다.

- 암호와 암호확인을 자바스크립트에서 정규표현식을 사용하여 유효성 검사를 수행한다.

- 이름 : 필수 파라미터 (*로 표시)

- 나머지는 선택 사항으로 입력하지 않아도 회원가입이 수행된다.

자바스크립트에서 회원가입 데이터에 대한 유효성 검사를 수행하고, 아이디 중복 확인 및 회원가입 요청을 서버로 전송한다.

$(document).ready(function() {
	var idCheck = false;	// 아이디 중복 검사 통과 여부
	var passwordValid = false;	// 비밀번호 유효성 검사 결과
	var password2Valid = false;	// 비밀번호 확인 유효성 검사 결과
	var emailValid = false;	// 이메일 유효성 검사 결과
	
	var token = $("meta[name='_csrf']").attr("content");
	var header = $("meta[name='_csrf_header']").attr("content");
	
	var formObj = $(".form-join");
	
	// 이메일 확인 유효성 검사
	$("input[name='email']").on("keyup", function(e) {
		var regExp = /^[-A-Za-z0-9_]+[-A-Za-z0-9_.]*[@]{1}[-A-Za-z0-9_]+[-A-Za-z0-9_.]*[.]{1}[A-Za-z]{2,5}$/;
		if( !regExp.test($("input[name='email']").val()) ) {
			$("input[name='email']").css("color", "#EE5656");
			emailValid = false;
		}
		else {
			$("input[name='email']").css("color", "blue");
			emailValid = true;
		}
	});
	
	// 비밀번호 유효성 검사
	$("input[name='password']").on("keyup", function(e) {
		var regExp = /^(?=.*[a-zA-Z])(?=.*[!@#$%^*+=-])(?=.*[0-9]).{5,15}$/;
		if( !regExp.test($("input[name='password']").val()) ) {
			$("input[name='password']").css("color", "#EE5656");
			passwordValid = false;
		}
		else {
			$("input[name='password']").css("color", "blue");
			passwordValid = true;
		}
	});
	
	// 비밀번호 확인 유효성 검사
	$("input[name='password2']").on("keyup", function(e) {
		var regExp = /^(?=.*[a-zA-Z])(?=.*[!@#$%^*+=-])(?=.*[0-9]).{5,15}$/;
		if( !regExp.test($("input[name='password2']").val()) ) {
			$("input[name='password2']").css("color", "#EE5656");
			password2Valid = false;
		}
		else {
			$("input[name='password2']").css("color", "blue");
			password2Valid = true;
		}
	});

	// 아이디 중복 확인
	$(".btn-secondary").on("click", function(e) {
		var email = $("input[name='email']").val();
		if(email == null || email.length == 0) {
			alert("아이디(이메일 형식)를 입력하세요");
			return;
		}
		if(emailValid == false) {
			alert("고유한 이메일 형식의 아이디를 사용하세요.");
			return;
		}
		// 서버로 중복 확인 요청
		$.ajax({
		  url: '/member/idCheck',
		  data: {email: email},
		  type: 'POST',
		  beforeSend: function(xhr) {
	          xhr.setRequestHeader(header, token);
	      },
		  success: function(result){
			if(result == "ok") {
				idCheck = true;
				alert("사용할 수 있는 아이디 입니다.");
			} else {
				idCheck = false;
				alert("사용할 수 없는 아이디 입니다.");
			}
		  }
		}); //$.ajax
	});
	
	$(".btn-success").on("click", function(e) {
		e.preventDefault();
		var email = $("input[name='email']").val();
		if(email == null || email.length == 0) {
			alert("아이디를 입력하세요");
			email.focus();
			return;
		}
		
		if(idCheck == false) {
			alert("아이디 중복확인을 하세요");
			return;
		}
		
		if(emailValid == false) {
			alert("고유한 이메일 형식의 아이디를 사용하세요.");
			return;
		}
		
		var password = $("input[name='password']").val();
		if(password == null || password.length == 0) {
			alert("암호를 입력하세요");
			password.focus();
			return;
		}
		
		if(passwordValid == false) {
			alert("특수문자, 영문, 숫자의 조합으로 8자 이상 15자 이하를 사용하세요.");
			return;
		}
		
		var password2 = $("input[name='password2']").val();
		if(password2 == null || password2.length == 0) {
			alert("암호 확인을 입력하세요");
			password2.focus();
			return;
		}
		
		if(password2Valid == false) {
			alert("특수문자, 영문, 숫자의 조합으로 8자 이상 15자 이하를 사용하세요.");
			return;
		}
		
		if(password != password2) {
			alert("암호 확인을 입력하세요");
			password2.focus();
			return;
		}
		
		var name = $("input[name='name']").val();
		if(name == null || name.length == 0) {
			alert("이름을 입력하세요");
			name.focus();
			return;
		}

		console.log("submit clicked");
		formObj.submit();
	});
});

이메일 형식의 아이디를 입력하고 중복 확인 버튼을 클릭하면 자바스크립트 join.js에서 아이디를 입력 유무에 대한 유효성 검사를 수행한 후 Ajax를 사용하여 아이디 중복 확인(/member/idCheck)을 서버로 요청한다. 이때 아이디인 email을 파라미터로 전송한다. 또한 CSRF를 header에 전송하여야 한다. 아래 그림을 보면 X-CSRF-TOKEN 헤더에 CSRF 토큰이 전송됨을 확인할 수 있다.

 

MemberController에서는 /member/idCheck 요청에 대하여 처리를 한다.

	@PostMapping("idCheck")
	@ResponseBody
	public String idCheck(String email) {
		log.info("idCheck..." + email);
		if(memberService.idCheck(email)) {
			return "ok";
		}
		else {
			return "nok";
		}
	}

email을 파라미터로 얻고 MemberService의 idCheck() 메소드를 호출하여 아이디 중복 여부를 검사한다. 아이디가 중복되지 않을 경우 true가 반환되며, 브라우저로 "ok" 문자열을 반환한다.

아이디가 중복된 경우 idCheck()에서 false가 반환되며, 브라우저로 "nok" 문자열을 반환한다.

자바스크립트에서는 중복 확인 결과를 받아 사용자에게 알리고, 아이디 중복확인 결과를 변수에 저장한다.

이미 있는 사용자 일 경우는 "사용할 수 없는 아이디 입니다" 경고창이 보여진다.

MemberService.java 인터페이스는 다음과 같다. idCheck()와 join() 메소드를 추가하고 MemberDTO를 엔티티 Member로 변환하는 dtoToEntity() 메소드를 정의한다.

package com.example.sboot.member.service;

import java.util.HashSet;

import com.example.sboot.member.dto.MemberDTO;
import com.example.sboot.member.entity.Member;
import com.example.sboot.member.entity.MemberRole;

public interface MemberService {
	boolean idCheck(String userid);
	void join(MemberDTO member);
	
	default Member dtoToEntity(MemberDTO dto) {
		Member member = Member.builder()
				.email(dto.getEmail())
				.password(dto.getPassword())
				.name(dto.getName())
				.nickname(dto.getNickname())
				.phone(dto.getPhone())
				.address(dto.getAddress())
				.birthday(dto.getCalendar()+"."+dto.getYear()+"."+dto.getMonth()+"."+dto.getDate())
				.fromSocial(false)
				.roleSet(new HashSet<MemberRole>())
				.build();
		member.addMemberRole(MemberRole.USER);
		return member;
	}
}

MemberDTO는 브라우저로부터 입력되는 파라미터 데이터를 수신하기 위하여 정의되며, MemberService에서 Entity로 변환된다. 이때 파라미터의 변환이 수행된다. 어떻게 변환을 할지는 입력 데이터와 데이터베이스에 저장되는 형태에 따라 다를 수 있다.

package com.example.sboot.member.dto;

import java.time.LocalDateTime;

import lombok.Builder;
import lombok.Data;

@Data
@Builder
public class MemberDTO {
	private String email;
	private String password;
	private String name;
	private String nickname;
	private String phone;
	private String calendar;	// 양력(sonar), 음역(lunar)
	private String year;
	private String month;
	private String date;
	private String address;
	private boolean fromSocial;
	private LocalDateTime regDate;
	private LocalDateTime modDate;
}

MemberServiceImpl.java는 구현 객체로 다음과 같다. 데이터베이스 연동을 위해 MemberRepository를 주입받는다. MemberController로부터 idCheck() 요청을 받으면 MemberRepository로 email을 가진 사용자가 있는지 질의하여 아이디를 가진 사용자가 있는지 알 수 있다.

package com.example.sboot.member.service;

import java.util.Optional;

import javax.transaction.Transactional;

import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;

import com.example.sboot.member.dto.MemberDTO;
import com.example.sboot.member.entity.Member;
import com.example.sboot.member.repository.MemberRepository;

import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;

@Service
@RequiredArgsConstructor
@Log4j2
public class MemberServiceImpl implements MemberService {
	private final MemberRepository memberRepository;
	private final PasswordEncoder pwencoder;

	@Override
	public boolean idCheck(String email) {
		log.info("idcheck..." + email);
		
		Optional<Member> member = memberRepository.findById(email);
		return member.isEmpty();
	}

	@Transactional
	@Override
	public void join(MemberDTO memberDTO) {
		String encPw = pwencoder.encode(memberDTO.getPassword());
		memberDTO.setPassword(encPw);
		Member member = dtoToEntity(memberDTO);
		memberRepository.save(member);
	}
}

회원정보를 입력하고 Join 버튼을 클릭하면, 자바스크립트 join.js에서 회원정보에 대한 유효성 검사를 수행한 후 이상이 없으면 /member/join POST 요청을 서버로 보낸다.

브라우저에서 개발자 도구에서 제공하는 network 탭을 이용하면 서버로 전송되는 데이터를 확인할 수 있다.

MemberController에서는 /member/join POST 요청을 받아 처리한다. MemberService로 회원정보의 저장을 요청하고, 로그인 창으로 리다이렉트를 수행하며, 로그인 절차에서 회원의 아이디를 한번만 활용할 수 있도록 RedirectAttributes로 전달한다.

	@PostMapping("/join")
	public String join(MemberDTO member, RedirectAttributes rttr) {
		log.info("================================");
		log.info("register: " + member);
		memberService.join(member);
		rttr.addFlashAttribute("result", member.getEmail());
		return "redirect:/member/login";
	}

로그인 창으로 이동하며, 회원의 아이디인 이메일이 표시됨을 알 수 있다.

MySQL workbench를 사용하여 회원정보가 member 테이블 및 member_role_set 테이블에 저장이 되었는지를 확인한다.

이로서 회원가입 기능이 잘 동작하는 것을 확인할 수 있다.

password에 대하여 정책을 특수문자, 영문자, 숫자의 조합으로 6자 이상으로 사용하도록 요구하고 있으므로, 이전에 login.html(login.js)에서 유효성 검사를 위해 임시로 막아두었던 코드를 다시 수정하여 유효성 검사가 제대로 수행이 되도록한다.

	// 비밀번호 유효성 검사
	$("input[name='password']").on("keyup", function(e) {
		var regExp = /^(?=.*[a-zA-Z])(?=.*[!@#$%^*+=-])(?=.*[0-9]).{5,15}$/;
		if( !regExp.test($("input[name='password']").val()) ) {
			$("input[name='password']").css("color", "#EE5656");
			passwordValid = false;
		}
		else {
			$("input[name='password']").css("color", "blue");
			passwordValid = true;
		}
		passwordValid = true;
	});

로그인 절차가 잘 수행이 된다. 비밀번호에 대하여 특수문자, 영문자, 숫자의 조합으로 6자 이상을 입력하여야 로그인이 진행된다.

전체 소스코드는 다음과 같다.

sboot.zip
2.22MB

Posted by 세상을 살아가는 사람
,

이전 게시글에서는 커스텀 로그인 폼을 적용하여 로그인을 수행하였다. Authentication Provider를 사용하여 회원정보 테이블을 사용하여 UserDetailsService를 사용하여 사용자 프로파일을 세션에 저장하여 시큐리티에서 사용하도록 하였다.

여기서는 시큐리티 적용에 따른 세션 principal 정보를 이용하여 로그인 여부에 따른 메뉴를 다르게 보여주도록 하고, 로그아웃 처리를 하고자 한다.

현재 오른쪽 위에 있는 Dropdown 메뉴는 아래 그림과 같이 임의의 메뉴를 배치하여 자리만 확보하고 있는 상태이다. 이것을 로그인 여부에 따라 메뉴를 달리하도록 수정한다.

로그인 여부에 따라 메뉴를 다르게 하기 위하여 시큐리티에서 로그인 여부를 나타내는 sec:authorize="isAuthenticated()"나 sec:authorize="isAnonymous()"를 사용하여 메뉴를 달리한다.

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<th:block th:fragment="setContent(content)">
<head>
<meta charset="utf-8" />
<meta name="viewport"
	content="width=device-width, initial-scale=1, shrink-to-fit=no" />
<meta name="description" content="" />
<meta name="author" content="" />
<meta name="_csrf" th:content="${_csrf.token}"/>
<meta name="_csrf_header" th:content="${_csrf.headerName}"/>
<title>Talanton 스프링 부트 홈 페이지</title>
<!-- Favicon-->
<link rel="icon" type="image/x-icon" th:href="@{/assets/favicon.ico}" />
<!-- Core theme CSS (includes Bootstrap)-->
<link th:href="@{/css/styles.css}" rel="stylesheet" />
<!-- Bootstrap core JS-->
<script
	src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
<!-- Core theme JS-->
<script th:src="@{/js/jquery.min.js}"></script>
<script th:src="@{/js/scripts.js}"></script>
</head>
<body>
<div class="d-flex" id="wrapper">
	<!-- Sidebar-->
	<div class="border-end bg-white" id="sidebar-wrapper">
		<div class="sidebar-heading border-bottom bg-light">Start
			Bootstrap</div>
		<div class="list-group list-group-flush">
			<a class="list-group-item list-group-item-action list-group-item-light p-3"
				href="#!">Dashboard</a>
			<a class="list-group-item list-group-item-action list-group-item-light p-3"
				href="#!">Shortcuts</a>
			<a class="list-group-item list-group-item-action list-group-item-light p-3"
				href="#!">Overview</a>
			<a class="list-group-item list-group-item-action list-group-item-light p-3"
				href="#!">Events</a>
			<a class="list-group-item list-group-item-action list-group-item-light p-3"
				href="#!">Profile</a>
			<a class="list-group-item list-group-item-action list-group-item-light p-3"
				href="#!">Status</a>
		</div>
	</div>
	<!-- Page content wrapper-->
	<div id="page-content-wrapper">
		<!-- Top navigation-->
		<nav
			class="navbar navbar-expand-lg navbar-light bg-light border-bottom">
			<div class="container-fluid">
				<button class="btn btn-primary" id="sidebarToggle">Toggle
					Menu</button>
				<button class="navbar-toggler" type="button"
					data-bs-toggle="collapse"
					data-bs-target="#navbarSupportedContent"
					aria-controls="navbarSupportedContent" aria-expanded="false"
					aria-label="Toggle navigation">
					<span class="navbar-toggler-icon"></span>
				</button>
				<div class="collapse navbar-collapse" id="navbarSupportedContent">
					<ul class="navbar-nav ms-auto mt-2 mt-lg-0">
						<li class="nav-item active"><a class="nav-link" href="#!">Home</a></li>
						<li class="nav-item"><a class="nav-link" href="#!">Link</a></li>
						<li class="nav-item dropdown"><a
							class="nav-link dropdown-toggle" id="navbarDropdown" href="#"
							role="button" data-bs-toggle="dropdown" aria-haspopup="true"
							aria-expanded="false">Dropdown</a>
							<div class="dropdown-menu dropdown-menu-end" aria-labelledby="navbarDropdown">
								<a sec:authorize="isAuthenticated()" class="dropdown-item logoutBtn" href="#">Logout</a>
                            	<a sec:authorize="isAuthenticated()" class="dropdown-item" th:href="@{/member/profile(email=${#authentication.principal.member.email})}">MyPage</a>
                            	<a sec:authorize="isAnonymous()" class="dropdown-item" href="/member/login">Login</a>
                            	<a sec:authorize="isAnonymous()" class="dropdown-item" href="/member/join">Join</a>
                            	<a sec:authorize="isAnonymous()" class="dropdown-item" href="#">아이디 찾기</a>
                            	<a sec:authorize="isAnonymous()" class="dropdown-item" href="#">비밀번호 찾기</a>
							</div>
						</li>
					</ul>
				</div>
			</div>
		</nav>
		<!-- Page content-->
		<div class="container-fluid">
			<th:block th:replace = "${content}"></th:block>
		</div>
	</div>
</div>
<script>
var token = $("meta[name='_csrf']").attr("content");
var header = $("meta[name='_csrf_header']").attr("content");

// 사이드바 메뉴 토글
$("#sidebarToggle").click(function(e) {
    e.preventDefault();
    $("#wrapper").toggleClass("toggled");
});

// 로그아웃 처리
$(".logoutBtn").click(function(e) {
	e.preventDefault();
	$.ajax({
		url: '/member/logout',
		type: 'post',
		// CSRF 토큰 값을 헤더로 전송
		beforeSend: function(xhr) {
		    xhr.setRequestHeader(header, token);
		},
		success: function(data) {
			alert("로그아웃 되었습니다.");
			location.href = "/member/login";
		}
	});
});
</script>
</body>
</th:block>
</html>

로그인이 되지 않은 경우 아래와 같은 메뉴가 보여진다.

Login 메뉴를 클릭한 후 로그인을 거치면 다음과 같이 메뉴가 표시됨을 알 수 있다.

이전에 구현한 것이지만 Toggle Menu 버튼을 누르면 왼쪽의 sidebar 메뉴가 토글되는 것을 역시 확인할 수 있다. 이 부분은 basic.html의 하단부에 있는 자바스크립트에서 메뉴에 대하여 이벤트를 등록하고 이벤트가 발생하면 sidebar 메뉴를 토글시켜줌으로써 동작시킬 수 있다.

<script>
$("#sidebarToggle").click(function(e) {
    e.preventDefault();
    $("#wrapper").toggleClass("toggled");
});
</script>

로그아웃 처리는 오른쪽 위의 Dropdown 메뉴를 누르면 Logout 메뉴가 나타나는데, 그것을 누르면 basic.html 하단에서 자바스크립트에서 이벤트로 처리를 한다

<!-- head 영역에 저장 -->
<meta name="_csrf" th:content="${_csrf.token}"/>
<meta name="_csrf_header" th:content="${_csrf.headerName}"/>

<!-- 자바스크립트 영역 -->
<script>
var token = $("meta[name='_csrf']").attr("content");
var header = $("meta[name='_csrf_header']").attr("content");

// 사이드바 메뉴 토글
$("#sidebarToggle").click(function(e) {
    e.preventDefault();
    $("#wrapper").toggleClass("toggled");
});

// 로그아웃 처리
$(".logoutBtn").click(function(e) {
	e.preventDefault();
	$.ajax({
		url: '/member/logout',
		type: 'post',
		// CSRF 토큰 값을 헤더로 전송
		beforeSend: function(xhr) {
		    xhr.setRequestHeader(header, token);
		},
		success: function(data) {
			alert("로그아웃 되었습니다.");
			location.href = "/member/login";
		}
	});
});
</script>

.basic.html의 <head> 영역에서 <meta> 태그를 사용하여 "_csrf"와 "_csrf_header"에 대하여 값을 저장한 후 자바스크립트에서 $("meta[name='_csrf']".attr("content")를 사용하여 값을 가져와 사용한다.

CSRF가 활성화 되어 있는 경우 로그아웃을 POST 방식으로 처리를 하여야 하며, Ajax를 사용하여 처리를 한다. 또한 CSRF 값을 전송하여야 하므로 시큐리티에서 제공하는 "_csrf"와 "_csrf_header" 값을 사용한다.

기본적으로 security에서 로그아웃 처리를 지원하므로 다음과 같이 SecurityConfig에서 설정하면 동작한다. 아래와 같이 http.logout().logoutUrl("/member/logout"); 로그아웃 url을 추가해 준다.

package com.example.sboot.config;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;

import com.example.sboot.security.service.CustomUserDetailsService;

import lombok.extern.log4j.Log4j2;

@Configuration
@Log4j2
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
	@Autowired
    private CustomUserDetailsService userDetailsService;
	
	@Bean
    PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }
	
	@Override
	protected void configure(HttpSecurity http) throws Exception {
		http.formLogin().loginPage("/member/login");
		http.userDetailsService(userDetailsService);
		http.logout().logoutUrl("/member/logout");
	}
}

Dropdown 메뉴에서 Logout 버튼을 클릭하면 아래 그림과 같이 로그아웃이 처리되었다고 경고창이 뜨고, 다시 로그인 창으로 이동함을 알 수 있다.

Dropdown 메뉴도 로그인 이전의 메뉴가 표시됨을 알 수 있다.

소스코드는 아래와 같다.

sboot.zip
0.23MB

Posted by 세상을 살아가는 사람
,

이전 게시글에서는 회원정보를 사용하여 security를 통한 로그인 기능을 처리를 하였다. 세션 정보도 저장을 하였다.

여기서는 security 모듈에서 지정한 로그인 폼이 아니라 별도의 url을 사용하고 로그인 폼 및 처리를 하는 방법을 알아본다. url은 /member/login을 사용한다. 따라서 /member url을 처리하는 MemberController를 별도로 생성한다.

com.example.sboot.member.controller 패키지 밑에 MemberController를 다음과 같이 추가한다.

package com.example.sboot.member.controller;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;

import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;

@Controller
@RequestMapping("/member")
@RequiredArgsConstructor
@Log4j2
public class MemberController {
	@GetMapping("/login")
	public void login() {
		log.info("login...");
	}
}

로그인 폼을 위해 templates/member 팀에 login.html을 다음과 같이 추가한다.

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<th:block th:replace="~{/layout/basic :: setContent(~{this::content} )}">
    <th:block th:fragment="content">
    	<script src="/js/login.js"></script>
    	
        <h1 class="mt-4">Login Page</h1>
        <form class="form-signin" method="post" action="/member/login">
        	<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
        	<h2 class="form-signin-heading">Please sign in</h2>
        	<p>
          		<label for="username" class="sr-only">Username</label>
        		<input type="text" id="username" name="username" class="form-control" placeholder="Username" th:value="${result}" required autofocus>
        	</p>
        	<p>
        		<label for="password" class="sr-only">Password</label>
        		<input type="password" id="password" name="password" class="form-control" placeholder="Password" required>
        	</p>
			<p><input type='checkbox' name='remember-me'/> Remember me on this computer.</p>
        	<button class="btn btn-lg btn-primary btn-block" type="submit">Sign in</button>
        </form>
		<h2 class="form-signin-heading">Login with OAuth 2.0</h2>
		<table class="table table-striped">
 			<tr>
 				<td><a href="/oauth2/authorization/google">Google</a></td>
 			</tr>
		</table>
    </th:block>
</th:block>

로그인 폼 및 로그인 POST 처리를 위한 url을 /member/login으로 변경을 하였으므로 SecurityConfig.java 클래스의 시큐리티 설정 정보도 다음과 같이 변경한다.

package com.example.sboot.config;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;

import com.example.sboot.security.service.CustomUserDetailsService;

import lombok.extern.log4j.Log4j2;

@Configuration
@Log4j2
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
	@Autowired
    private CustomUserDetailsService userDetailsService;
	
	@Bean
    PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }
	
	@Override
	protected void configure(HttpSecurity http) throws Exception {
		http.formLogin().loginPage("/member/login");
		http.userDetailsService(userDetailsService);
		http.logout();
	}
}

login.html에서 파라미터의 유효성 검사를 처리하는 login.js는 다음과 같다. 아이디와 비밀번호에 대하여 유효성 검사를 한다. 유효성 검사가 통과되면 서버로 로그인 요청을 보낸다.

$(document).ready(function() {
	var useridValid = false;	// 아이디 유효성 검사 결과
	var passwordValid = false;	// 비밀번호 유효성 검사 결과
	
	var token = $("meta[name='_csrf']").attr("content");
	var header = $("meta[name='_csrf_header']").attr("content");
	
	var formObj = $(".form-signin");
	
	// 아이디 유효성 검사
	$("input[name='username']").on("keyup", function(e) {
		var regExp = /^[-A-Za-z0-9_]+[-A-Za-z0-9_.]*[@]{1}[-A-Za-z0-9_]+[-A-Za-z0-9_.]*[.]{1}[A-Za-z]{2,5}$/;
		if( !regExp.test($("input[name='username']").val()) ) {
			$("input[name='username']").css("color", "#EE5656");
			useridValid = false;
		}
		else {
			$("input[name='username']").css("color", "blue");
			useridValid = true;
		}
	});
	
	// 비밀번호 유효성 검사
	$("input[name='password']").on("keyup", function(e) {
		var regExp = /^(?=.*[a-zA-Z])(?=.*[!@#$%^*+=-])(?=.*[0-9]).{5,15}$/;
		if( !regExp.test($("input[name='password']").val()) ) {
			$("input[name='password']").css("color", "#EE5656");
			passwordValid = false;
		}
		else {
			$("input[name='password']").css("color", "blue");
			passwordValid = true;
		}
	});

	$(".btn-primary").on("click", function(e) {
		e.preventDefault();
		var userid = $("input[name='username']").val();
		if(userid == null || userid.length == 0) {
			alert("아이디를 입력하세요");
			userid.focus();
			return;
		}

		if(useridValid == false) {
			alert("이메일 형식의 고유한 아이디를 사용하세요.");
			return;
		}
		
		var password = $("input[name='password']").val();
		if(password == null || password.length == 0) {
			alert("암호를 입력하세요");
			password.focus();
			return;
		}
		
		if(passwordValid == false) {
			alert("특수문자, 영문, 숫자의 조합으로 8자 이상 15자 이하를 사용하세요.");
			return;
		}

		console.log("submit clicked");
		formObj.submit();
	});
});

basic.html에서 절대경로를 고려하기 위하여 다음과 같이 수정하여 주어야 한다. thymeleaf에서 url은 th:를 붙여주고 @{}를 사용하여야 한다.

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<th:block th:fragment="setContent(content)">
<head>
<meta charset="utf-8" />
<meta name="viewport"
	content="width=device-width, initial-scale=1, shrink-to-fit=no" />
<meta name="description" content="" />
<meta name="author" content="" />
<title>Simple Sidebar - Start Bootstrap Template</title>
<!-- Favicon-->
<link rel="icon" type="image/x-icon" th:href="@{/assets/favicon.ico}" />
<!-- Core theme CSS (includes Bootstrap)-->
<link th:href="@{/css/styles.css}" rel="stylesheet" />
<!-- Bootstrap core JS-->
<script
	src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
<!-- Core theme JS-->
<script th:src="@{/js/jquery.min.js}"></script>
<script th:src="@{/js/scripts.js}"></script>
</head>
<body>
<div class="d-flex" id="wrapper">
	<!-- Sidebar-->
	<div class="border-end bg-white" id="sidebar-wrapper">
		<div class="sidebar-heading border-bottom bg-light">Start
			Bootstrap</div>
		<div class="list-group list-group-flush">
			<a class="list-group-item list-group-item-action list-group-item-light p-3"
				href="#!">Dashboard</a>
			<a class="list-group-item list-group-item-action list-group-item-light p-3"
				href="#!">Shortcuts</a>
			<a class="list-group-item list-group-item-action list-group-item-light p-3"
				href="#!">Overview</a>
			<a class="list-group-item list-group-item-action list-group-item-light p-3"
				href="#!">Events</a>
			<a class="list-group-item list-group-item-action list-group-item-light p-3"
				href="#!">Profile</a>
			<a class="list-group-item list-group-item-action list-group-item-light p-3"
				href="#!">Status</a>
		</div>
	</div>
	<!-- Page content wrapper-->
	<div id="page-content-wrapper">
		<!-- Top navigation-->
		<nav
			class="navbar navbar-expand-lg navbar-light bg-light border-bottom">
			<div class="container-fluid">
				<button class="btn btn-primary" id="sidebarToggle">Toggle
					Menu</button>
				<button class="navbar-toggler" type="button"
					data-bs-toggle="collapse"
					data-bs-target="#navbarSupportedContent"
					aria-controls="navbarSupportedContent" aria-expanded="false"
					aria-label="Toggle navigation">
					<span class="navbar-toggler-icon"></span>
				</button>
				<div class="collapse navbar-collapse" id="navbarSupportedContent">
					<ul class="navbar-nav ms-auto mt-2 mt-lg-0">
						<li class="nav-item active"><a class="nav-link" href="#!">Home</a></li>
						<li class="nav-item"><a class="nav-link" href="#!">Link</a></li>
						<li class="nav-item dropdown"><a
							class="nav-link dropdown-toggle" id="navbarDropdown" href="#"
							role="button" data-bs-toggle="dropdown" aria-haspopup="true"
							aria-expanded="false">Dropdown</a>
							<div class="dropdown-menu dropdown-menu-end"
								aria-labelledby="navbarDropdown">
								<a class="dropdown-item" href="#!">Action</a> <a
									class="dropdown-item" href="#!">Another action</a>
								<div class="dropdown-divider"></div>
								<a class="dropdown-item" href="#!">Something else here</a>
							</div>
						</li>
					</ul>
				</div>
			</div>
		</nav>
		<!-- Page content-->
		<div class="container-fluid">
			<th:block th:replace = "${content}"></th:block>
		</div>
	</div>
</div>
<script>
$("#sidebarToggle").click(function(e) {
    e.preventDefault();
    $("#wrapper").toggleClass("toggled");
});
</script>
</body>
</th:block>
</html>

로그인 폼은 다음과 같다.

우선은 비밀번호에 대한 유효성 검사를 잠시 막아 놓고 로그인을 수행한다. 잘 수행이 된다.

소스코드는 아래와 같다.

sboot.zip
0.23MB

Posted by 세상을 살아가는 사람
,

이전 게시글에서는 회원관리 기능을 위한 프로파일을 정의하고 테스트 가입자를 추가하였다. (https://talanton.tistory.com/125)

여기서는 security를 적용하여, 회원정보 테이블에 저장된 데이터를 이용하여 인증을 하고 사용자 정보를 세션에 저장하는 과정을 기술한다.

이를 위해서는 사용자 인증을 위한 CustomUserDetailsServvice를 구현하고 이를 설정 파일 SecurityConfig에 추가하여야 한다.

package com.example.sboot.config;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;

import com.example.sboot.security.service.CustomUserDetailsService;

import lombok.extern.log4j.Log4j2;

@Configuration
@Log4j2
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
	@Autowired
    private CustomUserDetailsService userDetailsService;
	
	@Bean
    PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }
	
	@Override
	protected void configure(HttpSecurity http) throws Exception {
		http.formLogin();
		http.userDetailsService(userDetailsService);
		http.logout();
	}
}

PasswordEncoder 빈을 추가하고, 인증 수행 시 사용자 프로파일 정보를 security principal에 저장하기 위한 CustomUserDetailsService를 주입 받아 http.userDetailsService(userDetailsService)로 추가하여야 한다. 사용자 프로파일을 가져오기 위한 메소드로 MemberRepository에 findByEmail() 메소드를 추가한다. 이는 사용자 정보를 검색하는 과정에서 아이디/비밀번호를 사용한 인증과 SNS 계정을 사용한 인증을 구별하기 위하여 fromSocial 필드를 추가한 것이다. 따라서 별도로 Repository에서 추가하였다.

package com.example.sboot.member.repository;

import java.util.Optional;

import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import com.example.sboot.member.entity.Member;

public interface MemberRepository extends JpaRepository<Member, String> {
	@EntityGraph(attributePaths = {"roleSet"}, type = EntityGraph.EntityGraphType.LOAD)
    @Query("select m from Member m where m.fromSocial = :social and m.email =:email")
    Optional<Member> findByEmail(@Param("email") String email, @Param("social") boolean social);
}

member_role_set 정보를 같이 가져오기 위하여 EntityGraph를 사용하였다. member 뿐만 아니라 member_role_set의 정보를 가지고 오는 것을 확인할 수 있다.

Entity member에 대응되는 DTO로 MemberDTO를 정의한다. 보통 DTO는 브라우저에서 Controller 및 Service까지 정보를 가지고 다니는 빈이며, Entity는 Repository에서 데이터베이스까지 데이터를 가지고 다니는 빈이다.

package com.example.sboot.member.dto;

import java.time.LocalDateTime;

import lombok.Builder;
import lombok.Data;

@Data
@Builder
public class MemberDTO {
	private String email;
	private String password;
	private String name;
	private String nickname;
	private String phone;
	private String calendar;	// 양력(sonar), 음역(lunar)
	private String year;
	private String month;
	private String date;
	private String address;
	private boolean fromSocial;
	private LocalDateTime regDate;
	private LocalDateTime modDate;
}

MemberDTO는 브라우저와 Controller 사이에 파라미터를 전달하기 위하여 Member 테이블과 차이가 있다.

AuthMemberDTO는 security를 위한 사용자 정보를 저장하는 DTO로 principal에 저장되는 빈이다.

package com.example.sboot.security.dto;

import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.User;

import com.example.sboot.member.dto.MemberDTO;

import java.util.Collection;
import java.util.Map;

@Getter
@Setter
@ToString
public class AuthMemberDTO extends User {
	private String name;
	private MemberDTO member;

    public AuthMemberDTO(MemberDTO member,
                             Collection<? extends GrantedAuthority> authorities, Map<String, Object> attr) {
        this(member, authorities);
    }

    public AuthMemberDTO(MemberDTO member,
                             Collection<? extends GrantedAuthority> authorities) {
        super(member.getEmail(), member.getPassword(), authorities);
        this.member = member;
        this.name = member.getName();
    }
}

AuthMemberDTO는 MemberDTO를 가지고 있도록 정의를 하였으며, User 클래스를 상속하여 필요한 name 속성을 가지도록 정의를 하였다.

security가 동작하는지 확인하기 위하여 http://localhost:8080/login url을 브라우저에서 입력하여 로그인 페이지를 보이도록한다.

앞에서 추가한 시험용 사용자 데이터로 로그인을 시도한다. 예를 들어 Username은 user9@zerock.org를 사용하고, Password는 1111을 입력한 후 Sign in 버튼을 클릭한다.

그러면 로고인이 수행되고 성공적으로 처리가 되어 메인 페이지로 이동한다.

console 창에서 출력되는 로그를 확인하면 /login 페이지가 출력되고

securing POST /login이 수행되면서 로그인 요청이 서버로 전달이 되고

ClubUserDetailsService에서 loadUserNyUsername메소드가 실행이 되고 Usrname user9@zerock.org가 처리됨을 알 수 있다.

또한 사용자의 회원정보를 다음과 같이 가지고 오는 것을 알 수 있다.

Hibernate: 
    select
        member0_.email as email1_0_,
        member0_.moddate as moddate2_0_,
        member0_.regdate as regdate3_0_,
        member0_.address as address4_0_,
        member0_.birthday as birthday5_0_,
        member0_.from_social as from_soc6_0_,
        member0_.name as name7_0_,
        member0_.nickname as nickname8_0_,
        member0_.password as password9_0_,
        member0_.phone as phone10_0_,
        roleset1_.member_email as member_e1_1_0__,
        roleset1_.role_set as role_set2_1_0__ 
    from
        member member0_ 
    left outer join
        member_role_set roleset1_ 
            on member0_.email=roleset1_.member_email 
    where
        member0_.from_social=? 
        and member0_.email=?

사용자 정보를 가져와서 세션에 저장하고 /로 이동함을 알 수 있다. 이로서 메인 페이지가 표시된다.

여기서는 사용자 데이터베이스를 사용하여 인증을 하고 세션 정보를 저장하는 것을 알 수 있다.

Posted by 세상을 살아가는 사람
,

이전 게시글에서는 스프링 부트를 이용해서 포트폴리오를 만들기 위하여 기본 프로젝트를 생성하였고, 부트스트랩을 적용하여 뷰에 대한 템플레이트 레이아웃을 적용하였다. 또한 기본 security를 적용하여 로그인까지 적용하였다. 그러나 아직 회원관리 기능이나 다른 기능이 적용되지 않은 상태이다.

이번 게시글에서는 회원관리 기능을 구현하기 위하여 사용자의 프로파일 정보를 구성하고, 데이터베이스 테이블을 생성하고 시험용 사용자를 만들고자 한다. security에서는 비밀번호를 암호화하여 저장하므로 역시 PasswordEncoder도 적용한다.

우선 사용자 프로파일 정보를 정의해 본다.

- email : 30자 이하

- password : 64 바이트

- name : 이름, 30바이트

- nickname : 별명, 30바이트 이하

- phone : 전화번호, 15바이트 이하

- address : 주소, 255바이트 이하

- birthday : 생년월일, 15바이트 이하 - s/l.2000.12.12 (s:양력, l:음력)

- fromSocial : SNS 회원가입 여부 (0: 기본, 1: SNS가입)

- regdate : 가입일

- moddate : 정보 변경일

스프링 부트에서는 JPA를 사용하고, Entity를 사용하여 데이터베이스 테이블을 조작한다. 모든 Entity는 기본적으로 regdate와 moddate가 기본적으로 사용되는 편이므로 BaseEntity라는 것을 정의하여 regdate와 moddate를 저장한다.

com.example.sboot.common.entity 패키지에 다음과 같이 BaseEntity.java를 추가한다.

package com.example.sboot.common.entity;

import java.time.LocalDateTime;

import javax.persistence.Column;
import javax.persistence.EntityListeners;
import javax.persistence.MappedSuperclass;

import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;

import lombok.Getter;

@MappedSuperclass		// 테이블로 생성되지 않음
@EntityListeners(value={AuditingEntityListener.class})	// Entity 객체가 생성/변경되는 것을 감지
@Getter
public abstract class BaseEntity {
	@CreatedDate		// JPA에서 엔티티의 생성시간을 자동으로 처리
	@Column(name="regdate", updatable=false)
	private LocalDateTime regDate;
	
	@LastModifiedDate	// JPA에서 엔티티의 수정시간을 자동으로 처리
	@Column(name="moddate")
	private LocalDateTime modDate;
}

BaseEntity.java는 객체화하지 못하도록 추상 클래스로 정의되며, @MappedSuperclass 어노테이션을 추가하여 JPA가 테이블로 생성하지 않토록한다. 또한 BaseEntity를 상속하여 만든 엔티티의 변경이 있을 경우 JPA에 의하여 자동으로 테이블이 변경되도록 하기 위하여 @EntityListeners(value={AuditingEntityListener.clss})를 추가한다. 또한 main() 메소드가 있는 실행 클래스인 SbootApplication.java에 @EnableJpaAuditing 어노테이션을 추가한다.

package com.example.sboot;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;

@SpringBootApplication
@EnableJpaAuditing
public class SbootApplication {
	public static void main(String[] args) {
		SpringApplication.run(SbootApplication.class, args);
	}
}

regDate와 modDate는 자바 8에서 추가된 LocalDateTime 클래스를 사용한다. regDate는 열이 추가될 때 자동으로 생성이 될 수 있도록 @CreatedDate 어노테이션을 추가하고, updatable=false로 하여 열 정보의 변경시 값이 변경이 되지 않토록한다. modDate는 열의 생성 및 열의 정보가 변경이 될 때마다 변경이 될 수 있도록 @LastModifiedDate 어노테이션을 추가한다.

회원정보를 저장하기 위한 entity로 com.example.sboot.member.entity 패키지에 Member.java를 다음과 같이 추가한다.

package com.example.sboot.member.entity;

import java.util.HashSet;
import java.util.Set;

import javax.persistence.Column;
import javax.persistence.ElementCollection;
import javax.persistence.Entity;
import javax.persistence.FetchType;
import javax.persistence.Id;

import com.example.sboot.common.entity.BaseEntity;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;

@Entity
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Getter
@ToString
public class Member extends BaseEntity {
	@Id
	@Column(length=30, nullable=false)
	private String email;
	@Column(length=64, nullable=false)
	private String password;
	@Column(length=30, nullable=false)
	private String name;
	@Column(length=30)
	private String nickname;
	@Column(length=15)
	private String phone;
	private String address;
	@Column(length=15)
	private String birthday;	// s/l.2000.12.12
	private boolean fromSocial;
	
	@ElementCollection(fetch = FetchType.LAZY)
    @Builder.Default
    private Set<MemberRole> roleSet = new HashSet<>();

    public void addMemberRole(MemberRole clubMemberRole){
        roleSet.add(clubMemberRole);
    }
}

security를 위해 사용자의 권한 정보를 저장하여야 하는데, 이를 위해 MemberRole enum을 다음과 같이 추가한다.

package com.example.sboot.member.entity;

public enum MemberRole {
	USER, MANAGER, ADMIN
}

권한 정보인 MemberRole은 USER, MANAGER, ADMIN과 같이 3가지 권한을 가지며, 사용자마다 여러 개의 권한을 부여할 수 있다. 이를 Member entity에서 접근할 수 있도록 Set<MemberRole>을 추가하고, 권한을 추가할 수 있는 메소드 addMemberRole()를 추가한다.

member 테이블과 member_role_set 테이블은 부모와 자식의 관계를 가지며, member 테이블의 아이디인 email을 PK/FK로 관계한다.

앱을 구동하면 다음과 같이 관련 테이블이 생성됨을 알 수 있다.

Hibernate: 
    
    create table member (
       email varchar(30) not null,
        moddate datetime(6),
        regdate datetime(6),
        address varchar(255),
        birthday varchar(15),
        from_social bit not null,
        name varchar(30) not null,
        nickname varchar(30),
        password varchar(64) not null,
        phone varchar(15),
        primary key (email)
    ) engine=InnoDB
Hibernate: 
    
    create table member_role_set (
       member_email varchar(30) not null,
        role_set integer
    ) engine=InnoDB
Hibernate: 
    
    alter table member_role_set 
       add constraint FKd46gobgko9rkxodwx1m14qit6 
       foreign key (member_email) 
       references member (email)

MySQL workbench를 사용하여 데이터베이스 테이블의 생성을 확인할 수 있다.

security를 적용하기 위하여 security 설정이 필요하다. 다음과 같이 com.example.sboot.config 폴더를 생성하고 SecurityConfig 클래스를 생성한다. 물론 WebSecurityConfigurerAdapter 클래스를 상속을 받아야 한다.

package com.example.sboot.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;

import lombok.extern.log4j.Log4j2;

@Configuration
@Log4j2
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {

	@Override
	protected void configure(HttpSecurity http) throws Exception {
		http.formLogin();
		http.logout();
	}
}

설정파일임을 나타내기 위하여 @Configuration 어노테이션을 추가하고, 어노테이션에 의한 시큐리티 설정을 위해 @EnabledGlobalMethodSecurity 어노테이션을 추가한다.

security에서 제공하는 로그인 페이지와 로그아웃 페이지를 적용하기 위하여 http.formLogin()과 http.logout()을 적용한다.

지금까지는 security에 의해서 default로 정의된 Username user와 발급된 비밀번호를 사용하여 인증이 이루어졌다. 그러나 회원정보를 사용하여 로그인이 이루어져야 하므로 우선 시험적으로 테스트 사용자를 생성하여야 한다. 이를 위해 member 테이블을 조작하기 위한 MemberRepository가 필요하다.

com.example.sboot.member.repository 패키지에 MemberRepository 인터페이스를 생성하며, JpaRepository<Member, String>을 상속한다. 기본적으로 JpaRepository는 모든 JPA 기능을 가지고 있으므로 어느 정도 데이터를 조작할 수 있는 메소드들을 제공하고 있다. 아래 그림의 상속관계를 살펴보면 일반적인 기능만을 가지는 CrudRepository를 가지며, PagingAndSortRepository 기능을 가지며, 그 보다 많은 기능을 가지는 것을 알 수 있다.

package com.example.sboot.member.repository;

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

import com.example.sboot.member.entity.Member;

public interface MemberRepository extends JpaRepository<Member, String> {

}

테스트 코드를 사용하여 시험적 사용자를 생성하기 위하여 src/test/java 폴더에 com.example.sboot.member.repository 패키지를 생성하고, 다음과 같이 MemberRepositoryTests.java 클래스를 추가한다.

package com.example.sboot.member.repository;

import java.util.HashSet;
import java.util.stream.IntStream;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.security.crypto.password.PasswordEncoder;

import com.example.sboot.member.entity.Member;
import com.example.sboot.member.entity.MemberRole;

@SpringBootTest
public class MemberRepositoryTests {
	@Autowired
    private MemberRepository repository;

    @Autowired
    private PasswordEncoder passwordEncoder;
    
    @Test
    public void insertDummies() {
        //1 - 6까지는 USER만 지정
        //7 - 8까지는 USER,MANAGER
        //9 - 10까지는 USER,MANAGER,ADMIN

        IntStream.rangeClosed(1,10).forEach(i -> {
            Member member = Member.builder()
                    .email("user"+i+"@zerock.org")
            		.password(passwordEncoder.encode("1111"))
                    .name("사용자"+i)
                    .nickname("똘이" + i)
                    .fromSocial(false)
                    .roleSet(new HashSet<MemberRole>())
                    .build();
    
            //default role
            member.addMemberRole(MemberRole.USER);
            if(i > 6){
                member.addMemberRole(MemberRole.MANAGER);
            }
            if(i > 8){
                member.addMemberRole(MemberRole.ADMIN);
            }
            repository.save(member);
        });
    }
}

10명의 사용자를 추가하며, 비밀번호를 암호화하여 저장한다. 이를 위해 PasswordEncoder를 사용하며, 위에서 정의한 MemberRepository를 사용한다.

- user1@zerock.org ~ user6@zerock.org는 USER 권한을 가지고

- user7@zerock.org ~ user8@zerock.org : MANAGER 권한

- user9@zerock.org ~ user10@zerock.org : ADMIN 권한

테스트 코드를 사용하여 JUnit 테이트를 실행한다. 테스트가 성공적으로 수행이 되며, MySQL workbench를 사용하여 확인하면 member 테이블에 사용자가 생성됨을 확인할 수 있다.

또한 member_role_set 테이블에는 사용자의 권한이 추가됨을 확인할 수 있다. user10@zerock.org는 USER, MANAGER, ADMIN 권한을 가짐을 알 수 있다.

소스코드는 다음과 같다.

sboot.zip
0.16MB

Posted by 세상을 살아가는 사람
,

이전 게시글에서는 스프링부트를 사용한 포트폴리오를 제작하기 위하여 기본 프로젝트를 생성하고, 프론트앤드에서 사용할 부트스트랩을 적용한 simple sidebar 템플레이트를 적용하였다.(https://talanton.tistory.com/123)

이번 글에서는 포트폴리오에서 사용할 뷰 페이지를 레이아웃을 적용하여 기본 템플레이트를 만든 다음에 페이지의 내용에 따라 뷰 페이지를 만드는 방법을 알아보고자 한다.

src/main/resources 폴더의 templates폴더에 layout 폴더를 생성하고 basic.html 파일을 생성한다.

index.html을 사용하여 basic.html을 다음과 같이 작성한다.

<th:block th:fragment="setContent(content)">태그와 Page content 부분에 <th:block th:replace = "${content}"></th:block>를 사용하여 각 뷰 페이지의 내용을 입력받도록 한다. 종료태그는 맨 마지막 </html> 태그 앞에 있다. 또 Bootstrap core JS와 Core theme JS를 </head> 태그 앞으로 이동한다. sidebarToggle 버튼을 클릭하면 사이드 바의 메뉴가 감춰지거나 보이도록 javascript(jQuery)를 사용하여 제어한다.

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<th:block th:fragment="setContent(content)">
<head>
<meta charset="utf-8" />
<meta name="viewport"
	content="width=device-width, initial-scale=1, shrink-to-fit=no" />
<meta name="description" content="" />
<meta name="author" content="" />
<title>Simple Sidebar - Start Bootstrap Template</title>
<!-- Favicon-->
<link rel="icon" type="image/x-icon" href="assets/favicon.ico" />
<!-- Core theme CSS (includes Bootstrap)-->
<link href="css/styles.css" rel="stylesheet" />
<!-- Bootstrap core JS-->
<script
	src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
<!-- Core theme JS-->
<script src="js/scripts.js"></script>
</head>
<body>
<div class="d-flex" id="wrapper">
	<!-- Sidebar-->
	<div class="border-end bg-white" id="sidebar-wrapper">
		<div class="sidebar-heading border-bottom bg-light">Start
			Bootstrap</div>
		<div class="list-group list-group-flush">
			<a class="list-group-item list-group-item-action list-group-item-light p-3"
				href="#!">Dashboard</a>
			<a class="list-group-item list-group-item-action list-group-item-light p-3"
				href="#!">Shortcuts</a>
			<a class="list-group-item list-group-item-action list-group-item-light p-3"
				href="#!">Overview</a>
			<a class="list-group-item list-group-item-action list-group-item-light p-3"
				href="#!">Events</a>
			<a class="list-group-item list-group-item-action list-group-item-light p-3"
				href="#!">Profile</a>
			<a class="list-group-item list-group-item-action list-group-item-light p-3"
				href="#!">Status</a>
		</div>
	</div>
	<!-- Page content wrapper-->
	<div id="page-content-wrapper">
		<!-- Top navigation-->
		<nav
			class="navbar navbar-expand-lg navbar-light bg-light border-bottom">
			<div class="container-fluid">
				<button class="btn btn-primary" id="sidebarToggle">Toggle
					Menu</button>
				<button class="navbar-toggler" type="button"
					data-bs-toggle="collapse"
					data-bs-target="#navbarSupportedContent"
					aria-controls="navbarSupportedContent" aria-expanded="false"
					aria-label="Toggle navigation">
					<span class="navbar-toggler-icon"></span>
				</button>
				<div class="collapse navbar-collapse" id="navbarSupportedContent">
					<ul class="navbar-nav ms-auto mt-2 mt-lg-0">
						<li class="nav-item active"><a class="nav-link" href="#!">Home</a></li>
						<li class="nav-item"><a class="nav-link" href="#!">Link</a></li>
						<li class="nav-item dropdown"><a
							class="nav-link dropdown-toggle" id="navbarDropdown" href="#"
							role="button" data-bs-toggle="dropdown" aria-haspopup="true"
							aria-expanded="false">Dropdown</a>
							<div class="dropdown-menu dropdown-menu-end"
								aria-labelledby="navbarDropdown">
								<a class="dropdown-item" href="#!">Action</a> <a
									class="dropdown-item" href="#!">Another action</a>
								<div class="dropdown-divider"></div>
								<a class="dropdown-item" href="#!">Something else here</a>
							</div>
						</li>
					</ul>
				</div>
			</div>
		</nav>
		<!-- Page content-->
		<div class="container-fluid">
			<th:block th:replace = "${content}"></th:block>
		</div>
	</div>
</div>
<script>
$("#sidebarToggle").click(function(e) {
    e.preventDefault();
    $("#wrapper").toggleClass("toggled");
});
</script>
</body>
</th:block>
</html>

home.html을 다음과 같이 수정하여 레이아웃 파일 basic.html을 적용한 메인 페이지를 만든다. <th:block> 태그를 사용하여 home.html 메인 페이지의 내용을 basic.html에 적용하여 메인 페이지를 구성한다. th:fragment="content" 부분에 home.html의 내용을 추가한다.

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<th:block th:replace="~{/layout/basic :: setContent(~{this::content} )}">
    <th:block th:fragment="content">
        <h1 class="mt-4">Main Page</h1>
    </th:block>
</th:block>

다시 앱을 구동하고 브라우저를 사용하여 http://localhost:8080 으로 접속을 한다. 다시 로그인 페이지로 이동하고, Username user와 비밀번호를 입력하여 로그인한다. 로그인이 성공하고 다시 메인 페이지로 이동한다. 따라서 레이아웃 파일이 적용됨을 알 수 있다.

동작원리는 아래 그림을 참조한다.

layout1.html의 내용이 exTemplate.html에 적용(대체)이 되고, layout1.html의 content class의 부분이 exTemplate.html의 th:fragment="content"의 내용으로 대체가 되는 것이다.

전체 소스 코드는 아래와 같다.

sboot.zip
0.14MB

Posted by 세상을 살아가는 사람
,

이전 글에서 새로운 스프링 부트 포트폴리오를 위한 프로젝트를 생성을 했었다. 아무 기능이 없이 그냥 테스트 코드를 실행을 하였고, Database 연동, Spring Data JPA 및 Security 라이브러리 설정을 하였었다.

이 글에서는 간단하게 메인 페이지에 대한 동작을 확인해 본다. 즉, 브라우저에서 http://localhost:8080/을 입력하면 메인 페이지가 표시되도록 한다.

이를 위해 HomeController.java를 com.example.sboot.main.controller에 추가한다. Controller로 동작하기 위하여 @Controller 어노테이션을 추가하고 메소드로 @GetMapping("/")를 가지는 home() 메소드를 추가하고 뷰 페이지인 /home을 반환한다.

package com.example.sboot.main.controller;

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

import lombok.extern.log4j.Log4j2;

@Controller
@Log4j2
public class HomeController {
	@GetMapping("/")
	public String home(Model model) {
		log.info("home...");
		return "/home";
	}
}

이 글에서는 뷰 페이지를 구성하기 위하여 부트스트랩에서 제공하는 Simple Sidebar 템플리트를 사용하여 화면을 구성하기 위한 설정을 알아본다.

기본적으로 다음 사이트(https://startbootstrap.com/template/simple-sidebar)에서 템플리트 파일을 다운로드 받아 사용한다. 압축을 풀어 src/main/resources 폴더 밑의 static 폴더에 저장한다.

 

Simple Sidebar - Bootstrap Sidebar Template - Start Bootstrap

Like our free products? Our pro products are even better! Go Pro Today!

startbootstrap.com

파일들

- assets/favicon.ico

- css/style.css

- js/script.js

- index.html

뷰 파일 home.html을 templates 폴더에 생성하고 index.html을 그대로 붙여넣기 한다.

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="utf-8" />
<meta name="viewport"
	content="width=device-width, initial-scale=1, shrink-to-fit=no" />
<meta name="description" content="" />
<meta name="author" content="" />
<title>Simple Sidebar - Start Bootstrap Template</title>
<!-- Favicon-->
<link rel="icon" type="image/x-icon" href="assets/favicon.ico" />
<!-- Core theme CSS (includes Bootstrap)-->
<link href="css/styles.css" rel="stylesheet" />
</head>
<body>
	<div class="d-flex" id="wrapper">
		<!-- Sidebar-->
		<div class="border-end bg-white" id="sidebar-wrapper">
			<div class="sidebar-heading border-bottom bg-light">Start
				Bootstrap</div>
			<div class="list-group list-group-flush">
				<a
					class="list-group-item list-group-item-action list-group-item-light p-3"
					href="#!">Dashboard</a> <a
					class="list-group-item list-group-item-action list-group-item-light p-3"
					href="#!">Shortcuts</a> <a
					class="list-group-item list-group-item-action list-group-item-light p-3"
					href="#!">Overview</a> <a
					class="list-group-item list-group-item-action list-group-item-light p-3"
					href="#!">Events</a> <a
					class="list-group-item list-group-item-action list-group-item-light p-3"
					href="#!">Profile</a> <a
					class="list-group-item list-group-item-action list-group-item-light p-3"
					href="#!">Status</a>
			</div>
		</div>
		<!-- Page content wrapper-->
		<div id="page-content-wrapper">
			<!-- Top navigation-->
			<nav
				class="navbar navbar-expand-lg navbar-light bg-light border-bottom">
				<div class="container-fluid">
					<button class="btn btn-primary" id="sidebarToggle">Toggle
						Menu</button>
					<button class="navbar-toggler" type="button"
						data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent"
						aria-controls="navbarSupportedContent" aria-expanded="false"
						aria-label="Toggle navigation">
						<span class="navbar-toggler-icon"></span>
					</button>
					<div class="collapse navbar-collapse" id="navbarSupportedContent">
						<ul class="navbar-nav ms-auto mt-2 mt-lg-0">
							<li class="nav-item active"><a class="nav-link" href="#!">Home</a></li>
							<li class="nav-item"><a class="nav-link" href="#!">Link</a></li>
							<li class="nav-item dropdown"><a
								class="nav-link dropdown-toggle" id="navbarDropdown" href="#"
								role="button" data-bs-toggle="dropdown" aria-haspopup="true"
								aria-expanded="false">Dropdown</a>
								<div class="dropdown-menu dropdown-menu-end"
									aria-labelledby="navbarDropdown">
									<a class="dropdown-item" href="#!">Action</a> <a
										class="dropdown-item" href="#!">Another action</a>
									<div class="dropdown-divider"></div>
									<a class="dropdown-item" href="#!">Something else here</a>
								</div></li>
						</ul>
					</div>
				</div>
			</nav>
			<!-- Page content-->
			<div class="container-fluid">
				<h1 class="mt-4">Simple Sidebar</h1>
				<p>The starting state of the menu will appear collapsed on
					smaller screens, and will appear non-collapsed on larger screens.
					When toggled using the button below, the menu will change.</p>
				<p>
					Make sure to keep all page content within the
					<code>#page-content-wrapper</code>
					. The top navbar is optional, and just for demonstration. Just
					create an element with the
					<code>#sidebarToggle</code>
					ID which will toggle the menu when clicked.
				</p>
			</div>
		</div>
	</div>
	<!-- Bootstrap core JS-->
	<script
		src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
	<!-- Core theme JS-->
	<script src="js/scripts.js"></script>
</body>
</html>

앱을 구동하고 브라우저에서 http://localhost:8080/으로 접속한다.

security에 의하여 인증을 요구하여 /login 페이지로 이동이 된다.

Username user와 앱 구동 시 발급된 비밀번호(아래 그림 참조)를 사용하여 로그인을 한다.

로그인이 성공하고 다시 http://localhost:8080으로 리다이렉트가 되고 아래 그림과 같이 부트스트랩 template가 표시됨을 알 수 있다.

이로서 간단하게 기본 security가 적용이 되면서 부트스트랩 template가 적용이 되는것까지 알아보고, 다음으로는 thymeleaf를 이용하여 기본 레이아웃 파일을 만들고 이를 적용하여 뷰 페이지를 만드는 방법을 알아본다.

전체 소스 코드는 아래와 같다.

sboot.zip
0.14MB

Posted by 세상을 살아가는 사람
,