점프투스프링부트(스프링부트+JPA)

3-07 요약 (로그인, 로그아웃 기능 구현)

여행자0 2024. 12. 17. 18:33

 

SecurityConfig 파일에 로그인, 로그아웃 설정 등록하기

package com.study.board;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.header.writers.frameoptions.XFrameOptionsHeaderWriter;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;

@Configuration
@EnableWebSecurity
@EnableMethodSecurity(prePostEnabled = true) //@PreAuthorize("isAuthenticated()") 이 메소드는 로그인사용자만 가능하도록
public class SecurityConfig {
	
    @Bean
    SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
        	// 모든 화면에 인증 체크 없이 접근 가능하게 함
            .authorizeHttpRequests((authorizeHttpRequests) -> authorizeHttpRequests
                .requestMatchers(new AntPathRequestMatcher("/**")).permitAll())
            //H2 콘솔 화면은 CSRF 토큰 체크 시 예외로 함
            .csrf((csrf) -> csrf
                .ignoringRequestMatchers(new AntPathRequestMatcher("/h2-console/**")))
            //H2 콘솔 화면 프레임 깨짐 방지 (X-Frame-Options 값을 SAMEORIGIN 으로)
            .headers((headers) -> headers
                    .addHeaderWriter(new XFrameOptionsHeaderWriter(
                        XFrameOptionsHeaderWriter.XFrameOptionsMode.SAMEORIGIN)))
            /*
             *스프링 시큐리티 로그인 설정
             *설정내용은 /user/login
             *로그인 성공시 이동할 페이지는 /
             *이거를 세팅하면 /user/login 에 대한 postmapping 은 따로 안해도 됨
             */
            .formLogin((formLogin) -> formLogin
                    .loginPage("/user/login")
                    .defaultSuccessUrl("/"))
            /*
             * 로그아웃 설정
             * invalidateHttpSession(true) : 로그아웃시 생성된 사용자 세션 삭제
             */
            .logout((logout) -> logout
                    .logoutRequestMatcher(new AntPathRequestMatcher("/user/logout"))
                    .logoutSuccessUrl("/")
                    .invalidateHttpSession(true))
        ;
        
        
        return http.build();
    }
    
    
    //암호화 빈 등록
    @Bean
    PasswordEncoder passwordEncoder() {
    	return new BCryptPasswordEncoder();
    }
    
    
    /*스프링 시큐리티 인증 처리(로그인) 담당
     *사용자 인증 시, UserSecurtyService와 PasswordEncoder을 내부적으로 사용하여
     *인증과 권한 부여 프로세스 처리
     */
    @Bean
    AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
        return authenticationConfiguration.getAuthenticationManager();
    }

}

 

filterChain 메서드에 .formLogin 과 .logout 부분을 추가했다.

 

.formLogin 메서드는 스프링 시큐리티의 로그인 설정을 담당한다. 

로그인 페이지의 URL 은 /user/login 이고, 로그인 성공하면 이동할 페이지는 루트 URL (/) 로 설정했다.

 

.logout 메서드는 스프링 시큐리티의 로그아웃 설정을 담당한다.

로그아웃 URL 은 /user/logout 이고, 로그아웃 성공하면 이동할 페이지는 루트 URL(/) 로 작성했다.

로그아웃 시 생성된 사용자 세션은 삭제하도록 .invalidateHttpSession(true) 로 설정했다.

 

 

그리고 맨 하단에 AuthenticationManager 빈을 생성하도록 작성했다.

이 메서드는 스프링 시큐리티의 인증 처리(로그인 처리)를 한다.

AuthenticationManager 는 후술할 UserSecurityService 와 PasswordEncoder 를 내부적으로 사용하여

인증과 권한 부여 프로세스를 처리한다.

 

 

회원 컨트롤러에 URL 매핑 추가

(... 생략 ...)

@RequiredArgsConstructor
@Controller
public class UserController {
	
    (... 생략 ...)
    
	@GetMapping("/user/login")
	public String getLogin() {
		return "loginForm";
	}
	
	// /user/login에 대한 postmapping은 스프링시큐리티가 대신처리함
	// 코드 작성할 필요가 없다

}

 

실제 로그인을 진행하는 PostMapping 은 스프링 시큐리티가 대신 처리하므로 구현할 필요가 없다.

GetMapping 만 추가한다.

 

 

로그인 템플릿 loginForm.html

<html layout:decorate="~{common/layout}">
<div layout:fragment="content" class="container my-3">
	
	<form th:action="@{/user/login}" method="post">
        <div th:if="${param.error}">
            <div class="alert alert-danger">
                사용자ID 또는 비밀번호를 확인해 주세요.
            </div>
        </div>
        <div class="mb-3">
            <label for="username" class="form-label">사용자ID</label>
            <input type="text" name="username" id="username" class="form-control">
        </div>
        <div class="mb-3">
            <label for="password" class="form-label">비밀번호</label>
            <input type="password" name="password" id="password" class="form-control">
        </div>
        <button type="submit" class="btn btn-primary">로그인</button>
    </form>
	
</div>
</html>

 

사용자 ID 와 비밀번호 입력 후, 로그인 버튼 클릭한다.

로그인이 실패했을 경우, 시큐리티의 기능으로 인해 로그인 페이지로 리다이렉트 된다. 

이때, 페이지 매개변수로 error 가 함께 전달된다.

에러 발생시 <div th:if="${param.error}"></div> 영역이 표출된다.

 

이 상태로는 로그인을 수행할 수 없다.

스프링 시큐리티가 무엇을 기준으로 로그인해야 하는지 아직 설정하지 않았기 때문이다.

 

스프링 시큐리티를 통해 로그인을 수행하는 방법에는 여러가지가 있는데,

그중에서 가장 간단한 방법으로 SecurityConfig.java 와 같은 시큐리티 설정 파일에 사용자 id와 비밀번호를 직접 등록하여 인증을 처리하는 메모리 방식이 있다.

하지만 이미 회원가입을 통해 회원 정보를 DB에 저장했으므로, DB에서 회원 정보를 조회하여 로그인 하는 방법을 사용할 것이다.

 

 

DB에서 사용자를 조회하는 서비스인 UserSecurityService.java 를 만들고,

그 서비스를 스프링 시큐리티에 등록하는 방법을 사용할 것이다.

UserRepository 와 UserRole 파일이 필요하다. 

 

 

UserRepository.java

package com.study.board.siteUser;

import java.util.Optional;

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

public interface UserRepository extends JpaRepository<SiteUser, Long>{

	//회원조회(사용자id(username)으로 조회)
	Optional<SiteUser> findByusername(String username);
}

 

 

UserRole.java 

package com.study.board.siteUser;

import lombok.Getter;

//권한은 enum 클래스 사용
//enum : 열거 자료형 [ ADMIN, USER 라는 상수 사용 ]
//위의 상수는 값을 변경할 필요 없으므로 @Setter는 안써도됨
@Getter
public enum UserRole {
	
	ADMIN("ROLE_ADMIN")
	,USER("ROLE_USER");
	
	private String value;
	
	UserRole(String value){
		this.value = value;
	}
}

 

스프링 시큐리티는 인증 뿐만 아니라 권한도 관리한다.

사용자 인증 후에, 사용자에게 부여할 권한과 관련된 내용이 필요하다.

그 권한을 UserRole 에서 참조할 것이다.

 

UserRole은 enum 자료형 (열거 자료형) 으로 작성했다.

ADMIN 과 USER 상수는 각각 'ROLE_ADMIN' , 'ROLE_USER' 라는 값을 가진다.

또 이러한 상수들은 값을 변경할 필요가 없으므로 @Getter 만 작성한다.

 

 

UserSecurityService.java

package com.study.board.siteUser;

import java.util.ArrayList;
import java.util.List;
import java.util.Optional;

import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

import lombok.RequiredArgsConstructor;


/*UserSecurityService 클래스는 스프링시큐리티가 로그인시 사용할 서비스이다.
 * 이 클래스는 UserDetailsService 인터페이스를 구현(implements) 해야 한다.
 * UserDetailsService를 구현 시, 반드시 loadUserByUsername 메서드를 반드시 구현해야 한다.
 * 위 메서드를 오버라이드를 통해 구현하고 있다.
 * 위 메서드는 스프링시큐리티의 User 객체를 리턴하는 메소드이다.
 */
@RequiredArgsConstructor
@Service
public class UserSecurityService implements UserDetailsService {
	
	private final UserRepository userRepository;
	
	@Override
	public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
    	
        //사용자id로 유저조회 
        Optional<SiteUser> _siteUser = this.userRepository.findByusername(username);
        
        if (_siteUser.isEmpty()) {
            throw new UsernameNotFoundException("사용자를 찾을수 없습니다.");
        }
        
        SiteUser siteUser = _siteUser.get();
        List<GrantedAuthority> authorities = new ArrayList<>();
        
        //username == admin일 경우 
        if ("admin".equals(username)) {
            authorities.add(new SimpleGrantedAuthority(UserRole.ADMIN.getValue()));
        } else {
            authorities.add(new SimpleGrantedAuthority(UserRole.USER.getValue()));
        }
        

        // 아래 User클래스는 스프링시큐리티에 내장된
        // org.springframework.security.core.userdetails.User 클래스이다. 
        // 사용자id,비번,권한리스트를 new 해서 return
        return new User(siteUser.getUsername(), siteUser.getPassword(), authorities);

        /*스프링시큐리티는 loadUserByUsername 메서드에 의해 리턴된 User 객체의 비밀번호가
         *사용자가 입력한 비밀번호와 일치하는지 검사하는 기능을 내부에 갖고 있음
         */
	}
	
	

}

 

상세한 설명은 주석에 달아놓았다. 다시 설명하자면

 

스프링 시큐리티가 로그인 시 사용할 UserSecurityService 는

스프링 시큐리티가 제공하는 UserDetailsService 인터페이스를 implements 해야 한다. 

 

UserDetailsService 는 loadUserByUsername 메서드를 구현하도록 강제하는 인터페이스다.

그래서 위 코드에서 @Override 를 통해 구현하고 있다. 

 

사용자 명이 admin 이면 ADMIN 권한 (ROLE_ADMIN) 을 부여하고, 

아니라면 USER 권한 (ROLE_USER) 을 부여한다. 

 

loadUserByUsername 메서드는

스프링 시큐리티에 내장된 User 클래스에 사용자 id, 비밀번호, 권한을 담아서 return 한다. 

또한 이 메서드에는 사용자가 입력한 비밀번호와, 실제 비밀번호가 일치하는지 검사하는 기능을 가지고 있다.

 

 

 

 

과정이 길었다. 정리하자면

스프링 시큐리티에서 로그인 처리를 하는 방법은,

먼저 SecurityConfig.java  파일에서 AuthenticatonManager 빈을 생성해야 한다. 

AuthenticatonManager 는 스프링 시큐리티의 인증을 처리(로그인 처리)를 담당한다.

 

AuthenticatonManager는 UserDetailsService 를 구현한 클래스인 UserSecurityService 와

PasswordEncoder 를 내부적으로 사용하여 인증과 권한 부여 프로세스를 처리한다.

 

UserSecurityService 를 작성하기 위해서

user를 조회해오기 위한 UserRepository 파일에 findByusername 메서드를 작성하였고,

user의 권한을 부여하기 위한 UserRole 파일을 작성하였다. 

 

navbar.html

네비게이션에는 로그인, 로그아웃 상태 표시와 회원가입 기능이 노출된다.

<!--공통템플릿으로 뺄때는 th:fragment 선언 필수!!! 다른 html에 고대로 붙일때는 이 선언만 빼면 됨 -->
<nav th:fragment="navbarFragment" class="navbar navbar-expand-lg navbar-light bg-light border-bottom">
	<div class="container-fluid">
		<a class="navbar-brand" href="/">BOARD</a>
		
		<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 me-auto mb-2 mb-lg-0">
				<li class="nav-item">
					<!--sec:authrize 속성 : 사용자의 로그인여부에 따라 요소를 출력하거나 출력하지 않음 -->
					<a class="nav-link" sec:authorize="isAnonymous()" th:href="@{/user/login}">로그인</a>
					<a class="nav-link" sec:authorize="isAuthenticated()" th:href="@{/user/logout}">로그아웃</a>
				</li>
				<li class="nav-item">
					<a class="nav-link" href="/user/signup">회원가입</a>
				</li>
			</ul>
		</div>
	</div>
</nav>

 

로그인과 로그아웃 a 태그의 링크를 각각 /user/login 과 /user/logout 으로 설정했다.

 

- 로그인 버튼 클릭시 -> /user/login 에 해당하는 GetMapping 호출 -> 화면에서 사용자id 와 비밀번호 작성 후 submit -> /user/login 에 해당하는 PostMapping은 작성하지 않았다. 내부적으로 스프링 시큐리티가 로그인 해줌 

 

- 로그아웃 버튼 클릭시 -> SecurityConfig.java 에서 .logout 메서드대로 /user/logout 가 요청됐으니, 로그아웃 후 루트 URL 로 보내주고, 세션도 삭제한다. 

 

a태그에 sec:authorize 라는 속성이 있다.

sec:authorize="isAnonymous()"  : 로그인 하지 않았을 때 해당  요소를 표출하라

sec:authorize="isAuthenticated()" : 로그인 했을 때 해당 요소를 표출하라