본문 바로가기

SpringBoot

SpringBoot(23) - 복습 예제(비밀번호 암호화 / 로그인 / 로그아웃), 추가 정리사항(경로)

728x90
반응형

1) SpringBoot

   1-1) 복습 예제

      1-1-1) 비밀번호 암호화 / 로그인 / 로그아웃 기능 구현

   1-2) 추가 정리사항(경로) 

 

 

 

 

 

1) SpringBoot

1-1) 복습 예제

[web5] 안에 build.gradle 파일 내용 아래와 같이 작성

plugins {
	id 'java'
	id 'org.springframework.boot' version '3.3.2'
	id 'io.spring.dependency-management' version '1.1.6'
}

group = 'net.datasa'
version = '0.0.1-SNAPSHOT'

java {
	toolchain {
		languageVersion = JavaLanguageVersion.of(17)
	}
}

configurations {
	compileOnly {
		extendsFrom annotationProcessor
	}
}

repositories {
	mavenCentral()
}

dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
	implementation 'org.springframework.boot:spring-boot-starter-security'
	implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
	implementation 'org.springframework.boot:spring-boot-starter-web'
	implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6'
	compileOnly 'org.projectlombok:lombok'
	developmentOnly 'org.springframework.boot:spring-boot-devtools'
	runtimeOnly 'com.mysql:mysql-connector-j'
	annotationProcessor 'org.projectlombok:lombok'
	testImplementation 'org.springframework.boot:spring-boot-starter-test'
	testImplementation 'org.springframework.security:spring-security-test'
	testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}

tasks.named('test') {
	useJUnitPlatform()
}

 

 

[src/main/resources] - [templates] 안에 application.properties 파일 내용 아래와 같이 작성

spring.application.name=web5

#접속 포트번호
server.port=8888
#Context Path
server.servlet.context-path=/

#Logback 사용. 전체를 대상으로 로깅 레벨 지정
#error>warn>info>debug>trace
logging.level.root=info
#특정 패키지를 대상으로 로깅 레벨 지정
logging.level.net.datasa.web5=debug

# MySQL 데이터베이스 설정
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.url=jdbc:mysql://localhost:3306/mydb?useSSL=false&serverTimezone=Asia/Seoul&useUnicode=true&characterEncoding=UTF-8
spring.datasource.username=root
spring.datasource.password=root

# JPA 설정
# DB 구조와 Entity가 안 맞을 때 에러를 내주는 역할을 하는 것이 바로 및의 "~ddl-auto=validate" 설정이다.
spring.jpa.hibernate.ddl-auto=validate
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL8Dialect
spring.jpa.properties.hibernate.format_sql=true
logging.level.org.hibernate.type.descriptor.sql=trace

spring.jackson.time-zone=Asia/Seoul

 

 

 

1-1-1) 로그인 / 로그아웃 기능 구현 기능 구현

(1) [src/main/resources] - [templates] 안에 home.html 파일 생성 후 아래와 같이 작성

<!DOCTYPE html>
<html lang="ko" xmlns:th="http://thymeleaf.org"
		xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
<head>
	<meta charset="UTF-8">
	<title>web5</title>
</head>
<body>
	<h1>[ web5 ]</h1>
	
	<div sec:authorize="not isAuthenticated()">
		<p>
			<a th:href="@{/member/joinForm}">회원가입</a>
		</p>
		<p>
			<a th:href="@{/member/loginForm}">로그인</a>
		</p>
	</div>
	
	<div sec:authorize="isAuthenticated()">
		<p>
			<a th:href="@{/member/logout}">로그아웃</a>
		</p>
		<p>
			<a href="@{/member/info}">개인정보 수정</a>
		</p>
	</div>
	
	<p>
		<a href="">게시판</a>
	</p>
	
</body>
</html>

 

 

(2) [src/main/java] - [net.datasa.web5.security] 안에 WebSecurityConfig.java 파일 생성 후 아래와 같이 작성

package net.datasa.web5.security;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
@EnableWebSecurity
public class WebSecurityConfig {
    //로그인 없이 접근 가능한 경로
    private static final String[] PUBLIC_URLS = {
            "/"                 //메인화면
            , "/member/joinForm"          //로그인 없이 접근할 수 있는 페이지
            , "/member/join"
            , "/member/idCheck"
            , "/images/**"
            , "/css/**"
            , "/js/**"
    };

    // Bean : 메모리에 만들어진 객체(즉, 객체를 생성해서 메모리에 로드시켜 놓는 역할을 한다!)
    @Bean
    protected SecurityFilterChain config(HttpSecurity http) throws Exception {
        http
            //요청에 대한 권한 설정
            .authorizeHttpRequests(author -> author
                .requestMatchers(PUBLIC_URLS).permitAll()   //모두 접근 허용
                // 모든 요청은 인증이 필요하다는 코드이며, 바로 위의 코드만 인증 없이 모두 접근 가능하다!
                .anyRequest().authenticated()               //그 외의 모든 요청은 인증 필요
            )
            //HTTP Basic 인증을 사용하도록 설정
            .httpBasic(Customizer.withDefaults())
            //폼 로그인 설정
            .formLogin(formLogin -> formLogin
                    .loginPage("/member/loginForm")              //로그인폼 페이지 경로
                    .usernameParameter("memberId")               //폼의 ID 파라미터 이름
                    .passwordParameter("memberPassword")         //폼의 비밀번호 파라미터 이름
                    .loginProcessingUrl("/member/login")         //로그인폼 제출하여 처리할 경로
                    .defaultSuccessUrl("/")                      //로그인 성공 시 이동할 경로
                    .permitAll()                                 //로그인 페이지는 모두 접근 허용
            )
            //로그아웃 설정
            .logout(logout -> logout
                    .logoutUrl("/member/logout")                 //로그아웃 처리 경로
                    .logoutSuccessUrl("/")                       //로그아웃 성공 시 이동할 경로
            );

        http
            .cors(AbstractHttpConfigurer::disable)
            .csrf(AbstractHttpConfigurer::disable);

        return http.build();
    }

    //비밀번호 암호화를 위한 인코더를 빈으로 등록
    @Bean
    public BCryptPasswordEncoder getPasswordEncoder() {
        return new BCryptPasswordEncoder();
    }

}

 

 

(3) [src/main/java] - [net.datasa.web5.controller] 안에 MemberController.java 파일 생성 후 아래와 같이 작성

package net.datasa.web5.controller;

import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import net.datasa.web5.domain.dto.MemberDTO;
import net.datasa.web5.service.MemberService;

/**
 * 회원정보 관련 Controller
 * */

@Slf4j
@RequestMapping("member")
@RequiredArgsConstructor
@Controller
public class MemberController {
	private final MemberService memberService;
	
	/**
	 * 회원가입 양식으로 이동
	 * */
	@GetMapping("joinForm")
	public String join() {
		return "memberView/joinForm";
	}
	
	@PostMapping("join")
	public String join(@ModelAttribute MemberDTO member) {
		log.debug("전달된 회원정보 : {}", member);
		
		// 서비스로 전달하여 저장
		memberService.memberJoin(member);
		
		return "redirect:/";
	}
	
	@GetMapping("idCheck")
	public String idCheck() {
		return "memberView/idCheck";
	}
	
	@PostMapping("idCheck")
	public String idCheck(
			@RequestParam("searchId") String searchId,
			Model model) {
		// ID 중복확인 폼에서 전달된 검색할 아이디를 받아서 log 출력
		log.debug("전달된 검색 ID : {}", searchId);
		
		// 서비스의 메소드로 검색할 아이디를 전달해서 조회
		// 해당 아이디를 쓰는 회원이 있으면 false, 없으면 true 리턴 받음
		boolean result = memberService.idCheck(searchId);
		
		log.debug("전달된 ID 검색 결과 : {}", result);
		
		// 검색한 아이디와 조회결과를 모델에 저장
		model.addAttribute("searchId", searchId);
		model.addAttribute("result", result);
		
		// 검색 페이지로 다시 이동
		return "memberView/idCheck";
	}
	
	// 로그인 폼
	@GetMapping("loginForm")
	public String login() {
		return "memberView/loginForm";
	}
	
}

 

 

(4) [src/main/java] - [net.datasa.web5.service] 안에 MemberService.java 파일 생성 후 아래와 같이 작성

package net.datasa.web5.service;

import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;

import jakarta.transaction.Transactional;
import lombok.RequiredArgsConstructor;
import net.datasa.web5.domain.dto.MemberDTO;
import net.datasa.web5.domain.entity.MemberEntity;
import net.datasa.web5.repository.MemberRepository;

/**
 * 회원정보 관련 처리 서비스
 * */

@RequiredArgsConstructor
@Service
@Transactional
public class MemberService {
	private final MemberRepository memberRepository;
	private final BCryptPasswordEncoder passwordEncoder;
	
	/**
	 * 가입 처리
	 * */
	public void memberJoin(MemberDTO dto) {
		MemberEntity entity = MemberEntity.builder()
				// DTO로 전달받은 값들을 Entity에 세팅
				.memberId(dto.getMemberId())
				.memberPassword(passwordEncoder.encode(dto.getMemberPassword()))
				.memberName(dto.getMemberName())
				.email(dto.getEmail())
				.phone(dto.getPhone())
				.address(dto.getAddress())
				// 기타 추가 데이터를 Entity에 세팅
				.enabled(true)
				.rolename("ROLE_USER")
				.build();
		
		// DB에 저장
		memberRepository.save(entity);
		
	}

	/**
	 * 전달받은 아이디를 DB에서 조회하여 사용중인지 여부 리턴
	 * @param searchId 조회할 아이디
	 * @return 있으면 사용불가 false, 없으면 사용가능 true
	 * */
	public boolean idCheck(String searchId) {
		
		return !memberRepository.existsById(searchId);
		
		/**
		 * // 바로 위의 코드와 동일한 기능을 수행하는 코드이다.
		 * // findById() : Primary Key 기준으로 검색을 하는 메서드(findById() : Optional 객체를 반환함)
		 * // orElse(null) : 결과가 없으면 "null" 값을 대입하는 메서드
		 * if (memberRepository.findById(searchId).isPresent()) {
			return false;
		} else {
			return true;
		}
		*/
		
	}

}

 

 

(5) [src/main/java] - [net.datasa.web5.security] 안에 AuthenticatedUser.java 파일 생성 후 아래와 같이 작성

package net.datasa.web5.security;

import java.util.Collection;
import java.util.Collections;

import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

@Builder
@Data
@NoArgsConstructor
@AllArgsConstructor
public class AuthenticatedUser implements UserDetails {
	// 객체 직렬화
	private static final long serialVersionUID = 1562050567301951305L;
	
	String id;
	String password;
	String roleName;
	boolean enabled;
	String name;

	@Override
	public Collection<? extends GrantedAuthority> getAuthorities() {
		// "ROLE_USER", "ROLE_ADMIN"
		return Collections.singletonList(new SimpleGrantedAuthority(roleName));
	}

	@Override
	public String getPassword() {
		return password;
	}

	@Override
	public String getUsername() {
		return id;
	}

	@Override
	public boolean isAccountNonExpired() {
		return true;
	}

	@Override
	public boolean isAccountNonLocked() {
		return true;
	}

	@Override
	public boolean isCredentialsNonExpired() {
		return true;
	}

	@Override
	public boolean isEnabled() {
		return enabled;
	}

}

 

 

(6) [src/main/java] - [net.datasa.web5.security] 안에 AuthenticatedUserDetailsService.java 파일 생성 후 아래와 같이 작성

package net.datasa.web5.security;

import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;

import jakarta.persistence.EntityNotFoundException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import net.datasa.web5.domain.entity.MemberEntity;
import net.datasa.web5.repository.MemberRepository;

@Slf4j
@Service
@RequiredArgsConstructor
public class AuthenticatedUserDetailsService implements UserDetailsService {

	private final BCryptPasswordEncoder passwordEncoder;
	private final MemberRepository memberRepository;
	
	@Override
	public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
		// 전달받은 아이디로 회원정보 DB에서 조회
		// 없으면 예외
		MemberEntity entity = memberRepository.findById(username)
				.orElseThrow(() -> new EntityNotFoundException("회원정보가 없습니다."));
		
		// 있으면 그 정보로 UserDetails 객체 생성하여 리턴함
		AuthenticatedUser user = AuthenticatedUser.builder()
				.id(username)
				.password(entity.getMemberPassword())
				.name(entity.getMemberName())
				.roleName(entity.getRolename())
				.enabled(entity.getEnabled())
				.build();
		log.debug("인증정보: {}", user);
		
		return user;
	}

}

 

 

(7) [src/main/resources] - [templates.memberView] 안에 loginForm.html 파일 생성 후 아래와 같이 작성

<!DOCTYPE html>
<html lang="ko" xmlns:th="http://thymeleaf.org"
	xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
<head>
	<meta charset="UTF-8">
	<title>로그인</title>
	<style>
		#loginArea {
			width: 700px;
			margin: 0 auto;
			text-align: center;
		}
		
		#loginForm {
			display: flex;
			flex-direction: column;
			justify-content: center;
		}
		
				
		#formArea {
			display: flex;
			justify-content: center;
		}
		
		table, tr, td {
			border: 2px solid black;
        	border-collapse: collapse;
        	padding: 10px;
        	margin: 20px 0 15px 0;
		}
		
		th {
			width: 100px;
			background-color: grey;
			color: white;
		}
		
		td {
			width: 300px;
			text-align: left;
		}
		
		td > .memberInfo {
			height: 25px;
		}
		
		#errorText {
			color: red;
			font-weight: bold;
			height:50px;
		}
	</style>
	<script th:src="@{/js/jquery-3.7.1.min.js}"></script>
	<script>
	$(document).ready(function() {
		$("#loginForm").submit(check);
	});
	
	function check() {
		let id = $("#memberId").val();
		let pw = $("#memberPassword").val();
		
		if (id.length == 0) {
			alert("ID를 반드시 입력해주세요!!");
			$("#memberId").focus();
			$("#memberId").val('');
			
	        return false;
		}
		
		if (pw.length == 0) {
			alert("비밀번호를 반드시 입력해주세요!!");
			$("#memberPassword").focus();
			$("#memberPassword").val('');
			
	        return false;
		}
		
		return true;
		
	}
	</script>
</head>
<body>
	<div id="loginArea">
		<h1>[ 로그인 ]</h1>
	
		<form id="loginForm" th:action="@{/member/login}" method="post">
			<div id="formArea">
				<table>
					<tr>
						<th>
							<label for="memberId">ID</label>
						</th>
						<td>
							<input type="text" name="memberId" id="memberId" class="memberInfo" placeholder="ID 입력하세요" />
						</td>
					</tr>
					<tr>
						<th>
							<label for="memberPassword">비밀번호</label>
						</th>
						<td>
							<input type="password" name="memberPassword" id="memberPassword" class="memberInfo" placeholder="비밀번호 입력하세요" />
						</td>
					</tr>
				</table>
			</div>
			
			<!-- 없는 ID이거나 비밀번호가 틀려서 로그인에 실패했을 경우 에러 문구 출력 -->
			<div th:if="${param.error}" id="errorText">
				ID 또는 비밀번호가 틀립니다.
			</div>
		
		   	<div id="btnArea">
	        	<input type="submit" value="로그인" />
	        	<input type="reset" value="취소" />
	        </div>
		</form>
	</div>
</body>
</html>

 

 

(5) 결과 화면

첫 접속 화면

 

"로그인" 문구 클릭 시 화면

- ID와 비밀번호 입력란에 이미 가입한 ID와 비밀번호를 입력할 시에만 로그인이 가능하도록 처리함

- 회원 ID가 없거나 비밀번호를 잘못 입력한 뒤 "로그인" 버튼을 클릭하면 로그인이 실패하면서 에러 문구(빨간색 글자)를 출력함

- 이미 가입한 ID와 그에 맞는 올바른 비밀번호를 입력 후 "로그인" 버튼을 클릭하면 다시 메인 페이지로 돌아옴

 

 

 

"로그아웃" 문구 클릭 시 화면

- 바로 위의 이미지에서 "로그아웃" 버튼을 클릭하면 위와 같이 로그인이 된 상태에서 로그아웃 상태로 페이지 내용을 바꿈

 

 

 

 

1-2) 추가 정리사항(경로) 

(예시 Case)

root : /shop

 

현재 : /member/joinForm

Target : /member/join

 

"join"

"/member/join"  >>  이 경우, "/shop/member/join"이 아닌 "/member/join"이 되어 버리기 때문에 경로 문제가 발생함!

 

Thymeleaf를 사용한 Case

@{join}  >>  /shop/member/join

@{/member/join}  >>  /shop/member/join

 

>> 따라서 경로 입력 시에는 Thymeleaf를 사용하는 것을 추천한다. 상대경로는 잘 못 쓰게 되면 에러 발생할 확률이 높다.