이전 게시글에서는 커스텀 로그인 폼을 적용하여 로그인을 수행하였다. 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 세상을 살아가는 사람
,

이전 게시글에서는 회원관리 기능을 위한 프로파일을 정의하고 테스트 가입자를 추가하였다. (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 세상을 살아가는 사람
,