[Spring Security] Custom Form Login 구현(2)

식빵·2022년 9월 10일
0

이 시리즈에 나오는 모든 내용은 인프런 인터넷 강의 - 스프링 시큐리티 - Spring Boot 기반으로 개발하는 Spring Security - 에서 기반된 것입니다. 그리고 여기서 인용되는 PPT 이미지 또한 모두 해당 강의에서 가져왔음을 알립니다. 추가적으로 여기에 작성된 코드들 또한 해당 강의의 github 에 올라와 있는 코드를 참고해서 만든 겁니다.



1. PasswordEncoder


저번 글에서 작성했던 Spring Security Config 소스를 다시 한번 보자.

@Configuration
@EnableWebSecurity
@Slf4j
public class SecurityConfig extends WebSecurityConfigurerAdapter {
	

	@Bean
	public PasswordEncoder passwordEncoder() {
		return new BCryptPasswordEncoder();
	}
	
    // ... 생략 ...
}

PasswordEncoder 는 이름 그대로 비밀번호 암호화를 책임지는 클래스의 인터페이스다.
이 인터페이스의 핵심 메소드는 아래와 같다.

public interface PasswordEncoder {
	String encode(CharSequence rawPassword);
    boolean matches(CharSequence rawPassword, String encodedPassword);
}

그렇다면 PasswordEncoder 를 직접 구현해야할까? 물론 그럴 수도 있겠지만,
Spring Security는 이미 PasswordEncoder 인터페이스를 구현한 다수의
클래스를 제공한다. 실습에서는 BCryptPasswordEncoder 를 사용할 것이다.
그리고 이 타입의 객체를 빈으로 등록함으로써 추후에 다른 Bean 에서 사용할 수 있도록 하자.





2. 회원가입 기능에서 사용


회원가입시에 비밀번호를 암호화해서 저장한다.


@Controller
@RequiredArgsConstructor
public class UserController {
	
	private final UserService userService;
	private final PasswordEncoder passwordEncoder; // DI!
	
    // ... 생략 ...

	@GetMapping("/users")
	public String createUser() {
		return "user/login/register";
	}
	
	@PostMapping("/users")
	public String createUser(AccountDto accountDto) {
		
		ModelMapper modelMapper = new ModelMapper();
		Account account = modelMapper.map(accountDto, Account.class);
		account.setPassword(passwordEncoder.encode(account.getPassword()));
		userService.createUser(account);
		
		return "redirect:/";
	}
}

이러고 실제 insert 를 하게되면 아래처럼 비밀번호가 암호화가 되어서 들어간 것을 확인할 수 있다.





3. UserDetailsService 구현


3-1. 메모리 방식의 사용자 정보 코드 삭제

이제는 DB를 통해서 사용자 정보를 읽을 것이기 때문에
기존에 Spring Security 설정 클래스에서 작성했던 메모리 방식의 사용자 정보 등록 코드를 지울 것이다. 아래처럼 주석처리 한다.

@Configuration
@EnableWebSecurity
@Slf4j
public class SecurityConfig extends WebSecurityConfigurerAdapter {
	
	// @Override
	// protected void configure(AuthenticationManagerBuilder auth) throws Exception {
	//	
	// 	String password = passwordEncoder().encode("1111");
	// 	auth.inMemoryAuthentication().withUser("user").password(password).roles("USER");
	// 	auth.inMemoryAuthentication().withUser("manager").password(password).roles("MANAGER");
	// 	auth.inMemoryAuthentication().withUser("admin").password(password).roles("ADMIN");
	// }
    
    // ... 생략 ...
}



3-2. CustomUserDetailsService 클래스 생성

package me.dailycode.security.service;

import lombok.RequiredArgsConstructor;
import me.dailycode.domain.Account;
import me.dailycode.repository.UserRepository;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;

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

@Service("userDetailsService")
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
	
	private final UserRepository userRepository;
	
	@Override
	public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
		
		Account account = userRepository.findByUsername(username);
		
		if (account == null) {
			throw new UsernameNotFoundException("UsernameNotFoundException");
		}
		
		List<SimpleGrantedAuthority> roles = new ArrayList<SimpleGrantedAuthority>();
		roles.add(new SimpleGrantedAuthority("ROLE_USER"));
		
		return new AccountContext(account, roles);
		
	}
}



3-3. User 를 상속한 커스텀 클래스 생성

package me.dailycode.security.service;

import me.dailycode.domain.Account;
import org.modelmapper.ModelMapper;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.User;

import java.util.Collection;

public class AccountContext extends User {
	
	private final Account account;
	
	private final ModelMapper modelMapper = new ModelMapper();
	
	public AccountContext(Account account, Collection<? extends GrantedAuthority> authorities) {
		super(account.getUsername(), account.getPassword(), authorities);
		// JPA 영역과 완전한 분리를 위해서 복사해서 새로운 Account 인스턴스를 생성한다.
		this.account = modelMapper.map(account, Account.class);
	}
	
	public Account getAccount() {
		return account;
	}
}



3-4. 설정하기

@Configuration
@EnableWebSecurity
@Slf4j
public class SecurityConfig extends WebSecurityConfigurerAdapter {

	//... 생략 ...
    
    	
	// @Override
	// protected void configure(AuthenticationManagerBuilder auth) throws Exception {
	//
	// 	String password = passwordEncoder().encode("1111");
	// 	auth.inMemoryAuthentication().withUser("user").password(password).roles("USER");
	// 	auth.inMemoryAuthentication().withUser("manager").password(password).roles("MANAGER");
	// 	auth.inMemoryAuthentication().withUser("admin").password(password).roles("ADMIN");
	// }
	
    
	@Autowired
	private UserDetailsService userDetailsService;
	
	@Override
	protected void configure(AuthenticationManagerBuilder auth) throws Exception {
		auth.userDetailsService(userDetailsService);
	}
}





4. CustomAuthenticationProvider


4-1. 구현

위에서 만든 커스텀 UserDetailsService 를 사용하는 CustomAuthenticationProvider를 생성해보자.

package me.dailycode.security.provider;

import me.dailycode.security.service.AccountContext;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.password.PasswordEncoder;

public class CustomAuthenticationProvider implements AuthenticationProvider {
	
	@Autowired
	private UserDetailsService userDetailsService;
	
	@Autowired
	private PasswordEncoder passwordEncoder;
	
	@Override
	public Authentication authenticate(Authentication authentication) throws AuthenticationException {
		
		String username = authentication.getName();
		String password = (String)authentication.getCredentials();
		
		AccountContext accountContext = (AccountContext) userDetailsService.loadUserByUsername(username);
		
		if (!passwordEncoder.matches(password, accountContext.getPassword())) {
			throw new BadCredentialsException("BadCredentialsException'");
		}
		
		return new UsernamePasswordAuthenticationToken(
				accountContext.getAccount(),
				null,
				accountContext.getAuthorities()
			);
	}
	
	@Override
	public boolean supports(Class<?> authentication) {
		return UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication);
	}
}

참고: 개인적 궁금증에 대한 자문자답
Q. Authenticaion.principal 에 Account 정보를 준 이유는? 그냥 aacountContext 를 주면 되는 거 아닌가?
A. https://stackoverflow.com/questions/37499307/whats-the-principal-in-spring-security 를 참고하면 principal 은 현재 로그인 사용자 자체를 의미하는 것이다.
그러므로 Wrap 한 클래스가 아닌 그냥 순수한 사용자 데이터를 넣기 위해서다.
하지만 이건 어디까지나 구현하는 사람의 마음이다.



4-2. 적용

Spring Security 설정 클래스에 만든 CustomAuthenticationProvider를 적용하자.


// @Bean
// public UserDetailsService userDetailsService() {
// 	return new CustomUserDetailsService();
// }

// @Autowired
// private UserDetailsService userDetailsService;
//
// @Override
// protected void configure(AuthenticationManagerBuilder auth) throws Exception {
// 	auth.userDetailsService(userDetailsService);
// }

protected void configure(AuthenticationManagerBuilder auth) throws Exception {
    auth.authenticationProvider(authenticationProvider());
}

@Bean
public AuthenticationProvider authenticationProvider() {
    return new CustomAuthenticationProvider();
}





5. 커스텀 로그인 페이지 적용


5-1. 로그인 페이지 설정 추가

@Configuration
@EnableWebSecurity
@Slf4j
public class SecurityConfig extends WebSecurityConfigurerAdapter {
	
    // ... 생략...
    
	@Override
	protected void configure(HttpSecurity http) throws Exception {
		http
			.authorizeRequests()
			.antMatchers("/", "/users").permitAll()
			.antMatchers("/mypage").hasRole("USER")
			.antMatchers("/messages").hasRole("MANAGER")
			.antMatchers("/config").hasRole("ADMIN")
			.anyRequest().authenticated()
			.and()
			.formLogin()
			.loginPage("/login")	// 커스텀 로그인 페이지 설정!
			.loginProcessingUrl("/login_proc")	// 로그인 post url 지정
			.defaultSuccessUrl("/")		// 로그인 성공 티폴트 redirect 경로
			.permitAll(); // 커스텀 로그인 페이지를 설정했으니 permitAll 해준다.
	}
	
    // ... 생략...
}



5-2. 로그인 페이지(HTML) 작성

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head th:replace="layout/header::userHead"></head>
<body>
<div th:replace="layout/top::header"></div>
<div class="container text-center">
    <div class="login-form d-flex justify-content-center">
        <div class="col-sm-5" style="margin-top: 30px;">
            <div class="panel">
                <p>아이디와 비밀번호를 입력해주세요</p>
            </div>
            <form th:action="@{/login_proc}" class="form-signin" method="post">
                <div class="form-group">
                    <input type="text" class="form-control" name="username" placeholder="아이디" required="required" autofocus="autofocus" />
                </div>
                <div class="form-group">
                    <input type="password" class="form-control" name="password" placeholder="비밀번호" required="required" />
                </div>
                <!--
                <div class="form-group">
                    Remember Me<input type="checkbox" name="remember-me" />
                </div>-->
                <button type="submit" class="btn btn-lg btn-primary btn-block">로그인</button>
            </form>
        </div>
    </div>
</div>
</body>
</html>



5-3. Header HTML 작성

<!DOCTYPE html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<head th:fragment="userHead">
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
    <title>Home</title>
    <script th:src="@{/js/jquery-2.1.3.min.js}"></script>
    <link rel="stylesheet" th:href="@{/css/base.css}" />
    <link rel="stylesheet" th:href="@{https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/css/bootstrap.min.css}" />
</head>
</html>



5-4. LoginController 생성

package me.dailycode.controller.login;

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

@Controller
public class LoginController {
	
	@GetMapping("/login")
	public String login() {
		return "user/login/login";
	}
}





6. 로그아웃 및 인증상태에 따른 화면처리


로그아웃 방법은 크게 2가지다.

  • form 태그를 사용해서 Post 로 요청
  • a 태그를 사용해서 GET 으로 요청 : SecurityContextLogoutHandler 활용

2번째 방법을 사용하도록 할 것이다.

6-1. Header Html 수정

<!DOCTYPE html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org"
      xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity5">
<!-- ns 추가 -->
<div th:fragment="header">
  <nav class="navbar navbar-dark sticky-top bg-dark ">
    <div class="container">
      <a class="text-light" href="#"><h4>Core Spring Security</h4></a>
      <ul class="nav justify-content-end">
        <li class="nav-item" sec:authorize="isAnonymous()" ><a class="nav-link text-light" th:href="@{/login}">로그인</a></li>
        <!-- 로그아웃 버튼 추가 -->
        <li class="nav-item" sec:authorize="isAuthenticated()"><a class="nav-link text-light" th:href="@{/logout}">로그아웃</a></li>
        <li class="nav-item" sec:authorize="isAnonymous()"><a class="nav-link text-light" th:href="@{/users}">회원가입</a></li>
        <li class="nav-item" ><a class="nav-link text-light" href="/">HOME</a></li>
      </ul>
    </div>
  </nav>
</div>
</html>



6-2. Controller 메소드 추가

import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

@Controller
public class LoginController {
	
	@GetMapping("/login")
	public String login() {
		return "user/login/login";
	}
	
	@GetMapping("/logout")
	public String logout(HttpServletRequest request, HttpServletResponse response) {
		
		Authentication authentication = 
        	SecurityContextHolder.getContext().getAuthentication();
		
		if (authentication != null) {
			new SecurityContextLogoutHandler().logout(request, response, authentication);
		}
		
		return "redirect:/login";
	}
}

new SecurityContextLogoutHandler().logout(request, response, authentication);
처럼 호출만 하면 끝!

실제 저 버튼을 누르면 로그아웃 처리가 된다.

profile
백엔드를 계속 배우고 있는 개발자입니다 😊

0개의 댓글