스프링/스프링 시큐리티

스프링 시큐리티-커스텀 로그인 구현

coffee. 2023. 12. 30. 19:21

시큐리티 인증 과정

스프링 시큐리티의 인증 과정은 다음과 같습니다.

-사용자의 요청을 필터가 가로챈다.

-UsernamePasswordAuthenticaionToken의 인증용 객체생성(username,password)포함

-AuthenticationManager에게 authentication객체 전달

-AuthenticationManager가 적당한 provider를 찾습니다.

-provider는 userdetails와 userdetailsService를 통해 아이디와 패스워드가 일치하는지 확인합니다.

-최종적으로 securityContextholder에 authentication객체를 저장합니다.

 

스프링 시큐리티에서는 securityContext에  authentication객체를 저장하였을때 인증되었다고 합니다.

기본적으로 제공하는 UserDetails 객체는 username과password,권한 정도를 저장합니다.

하지만 이 정보 이외에 이메일을 저장하고 싶다면 어떻게 할까요?

provider와 userDetails ,그리고 userDetailsService를 직접 구현하여 해결 할 수 있습니다.

 

 

먼저, UsernamePasswordAuthenticationFilter의 구조를 보자

public class UsernamePasswordAuthenticationFilter extends AbstractAuthenticationProcessingFilter {

	public static final String SPRING_SECURITY_FORM_USERNAME_KEY = "username";

	public static final String SPRING_SECURITY_FORM_PASSWORD_KEY = "password";

	private static final AntPathRequestMatcher DEFAULT_ANT_PATH_REQUEST_MATCHER = new AntPathRequestMatcher("/login",
			"POST");

	private String usernameParameter = SPRING_SECURITY_FORM_USERNAME_KEY;

	private String passwordParameter = SPRING_SECURITY_FORM_PASSWORD_KEY;

	private boolean postOnly = true;

	public UsernamePasswordAuthenticationFilter() {
		super(DEFAULT_ANT_PATH_REQUEST_MATCHER);
	}

	public UsernamePasswordAuthenticationFilter(AuthenticationManager authenticationManager) {
		super(DEFAULT_ANT_PATH_REQUEST_MATCHER, authenticationManager);
	}

	@Override
	public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
			throws AuthenticationException {
		if (this.postOnly && !request.getMethod().equals("POST")) {
			throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
		}
		String username = obtainUsername(request);
		username = (username != null) ? username.trim() : "";
		String password = obtainPassword(request);
		password = (password != null) ? password : "";
		UsernamePasswordAuthenticationToken authRequest = UsernamePasswordAuthenticationToken.unauthenticated(username,
				password);
		// Allow subclasses to set the "details" property
		setDetails(request, authRequest);
		return this.getAuthenticationManager().authenticate(authRequest);
	}

	/**
	 * Enables subclasses to override the composition of the password, such as by
	 * including additional values and a separator.
	 * <p>
	 * This might be used for example if a postcode/zipcode was required in addition to
	 * the password. A delimiter such as a pipe (|) should be used to separate the
	 * password and extended value(s). The <code>AuthenticationDao</code> will need to
	 * generate the expected password in a corresponding manner.
	 * </p>
	 * @param request so that request attributes can be retrieved
	 * @return the password that will be presented in the <code>Authentication</code>
	 * request token to the <code>AuthenticationManager</code>
	 */
	@Nullable
	protected String obtainPassword(HttpServletRequest request) {
		return request.getParameter(this.passwordParameter);
	}

	/**
	 * Enables subclasses to override the composition of the username, such as by
	 * including additional values and a separator.
	 * @param request so that request attributes can be retrieved
	 * @return the username that will be presented in the <code>Authentication</code>
	 * request token to the <code>AuthenticationManager</code>
	 */
	@Nullable
	protected String obtainUsername(HttpServletRequest request) {
		return request.getParameter(this.usernameParameter);
	}

	/**
	 * Provided so that subclasses may configure what is put into the authentication
	 * request's details property.
	 * @param request that an authentication request is being created for
	 * @param authRequest the authentication request object that should have its details
	 * set
	 */
	protected void setDetails(HttpServletRequest request, UsernamePasswordAuthenticationToken authRequest) {
		authRequest.setDetails(this.authenticationDetailsSource.buildDetails(request));
	}

	/**
	 * Sets the parameter name which will be used to obtain the username from the login
	 * request.
	 * @param usernameParameter the parameter name. Defaults to "username".
	 */
	public void setUsernameParameter(String usernameParameter) {
		Assert.hasText(usernameParameter, "Username parameter must not be empty or null");
		this.usernameParameter = usernameParameter;
	}

	/**
	 * Sets the parameter name which will be used to obtain the password from the login
	 * request..
	 * @param passwordParameter the parameter name. Defaults to "password".
	 */
	public void setPasswordParameter(String passwordParameter) {
		Assert.hasText(passwordParameter, "Password parameter must not be empty or null");
		this.passwordParameter = passwordParameter;
	}

	/**
	 * Defines whether only HTTP POST requests will be allowed by this filter. If set to
	 * true, and an authentication request is received which is not a POST request, an
	 * exception will be raised immediately and authentication will not be attempted. The
	 * <tt>unsuccessfulAuthentication()</tt> method will be called as if handling a failed
	 * authentication.
	 * <p>
	 * Defaults to <tt>true</tt> but may be overridden by subclasses.
	 */
	public void setPostOnly(boolean postOnly) {
		this.postOnly = postOnly;
	}

	public final String getUsernameParameter() {
		return this.usernameParameter;
	}

	public final String getPasswordParameter() {
		return this.passwordParameter;
	}

}

보시면 아시겠지만 기본적으로 이 필터는 POST방식의 "/login" 요청을 가로챕니다.

또한 username 파라미터와 password파라미터를 사용하여 값을 꺼내옵니다.

따라서 로그인 폼에서 input태그의 name을 각각 username과 password로 맞추어 주어야 하는 것이죠

 

 

실습을 위한 기본 설정

 

 

application.properties

spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.url=jdbc:mysql://localhost:3306/security?serverTimezone=Asia/Seoul
spring.datasource.username=root
spring.datasource.password=

    
    
    
spring.jpa.hibernate.ddl-auto=create
spring.jpa.show-sql=true

 

 

디렉터리 구조

디렉터리 구조

SecurityConfig

package com.example.demo.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;

import com.example.demo.auth.CustomAuthenticationProvider;

@Configuration
public class SecurityConfig {

	
	@Bean
	public BCryptPasswordEncoder bCryptPasswordEncoder() {
		return new BCryptPasswordEncoder();
	}
	
	@Bean
	public AuthenticationProvider provider() {
		return new CustomAuthenticationProvider();
	}
	
	
	@Bean
	public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
		http.csrf().disable();
		
		http.authorizeHttpRequests()
			.requestMatchers("/user/**").authenticated()
			.requestMatchers("/manager/**").hasRole("MANAGER")
			.requestMatchers("/admin/**").hasRole("ADMIN")
			.anyRequest().permitAll()
			.and()
			.formLogin().loginPage("/loginForm").permitAll()
			.loginProcessingUrl("/login")
			.defaultSuccessUrl("/");
		
		http.authenticationProvider(provider());
			
		
		return http.build();
		
	}
	
}

/user url로 접근할 시에는 인증이 요구됩니다.

로그인 페이지는 /loginForm 으로 연결됩니다.

 

 

MainController

package com.example.demo.controller;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.ResponseBody;

import com.example.demo.auth.CustomUserDetails;
import com.example.demo.entity.User;
import com.example.demo.repository.UserRepository;

import jakarta.servlet.http.HttpServletRequest;

@Controller
public class MainController {
	
	@Autowired
	private BCryptPasswordEncoder passwordEncoder;

	@Autowired
	private UserRepository repository;
	
	@GetMapping("/")
	public @ResponseBody String index() {
		return "main";
	}
	
	@GetMapping("/loginForm")
	public String loginForm() {
		return "loginForm";
	}
	
	@GetMapping("/joinForm")
	public String joinForm() {
		return "joinForm";
	}
	
	@PostMapping("/join")
	public String join(User user,HttpServletRequest req) {
		
		user.setRole("ROLE_USER");
		user.setPassword(passwordEncoder.encode(user.getPassword()));
		user.setEmail(req.getParameter("email"));
		repository.save(user);
		return "redirect:/";
	}
	
	@GetMapping("/user")
	public @ResponseBody String user() {
		return "user";
	}
	
	
	
}

요청처리를 확인하기 위한 간단한 컨트롤러 입니다.

/join은 회원가입을 처리하며 /user는 인증이 완료되었다면 "user"문자열을 보실수 있습니다.

 

 

 

 

로그인 페이지와 회원가입 페이지 html

<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>회원가입 페이지</title>
</head>
<body>
<h1>회원가입페이지</h1>
<form action="/join" method="POST">
	<input type="text" name="username" placeholder="Username"/><br/>
	<input type="password" name="password" placeholder="Password" />
	<input type="email" name="email" placeholder="email" />
	<button>회원가입</button>
</form>
</body>
</html>

 

 

<html>
<head>
	<title>login page</title>
	<meta charset="UTF-8">	
</head>

<body>
	<form action="/login" method="POST">
		<input type="text"  name="username" placeholder="username"/>
		<input type="text" name="password" placeholder="password"/>
		<input type="submit" value="로그인"/>
	</form>
	
	<a href="/joinForm">회원가입을 안하셨나요?</a>
</body>
</html>

 

 

 


User Entity

package com.example.demo.entity;


import java.sql.Timestamp;

import org.hibernate.annotations.CreationTimestamp;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import lombok.Data;

@Entity
@Data
public class User {

	@Id
	@GeneratedValue(strategy = GenerationType.IDENTITY)
	private int id;
	private String username;
	private String password;
	private String email;
	private String role;
	@CreationTimestamp
	private Timestamp createDate;
}

 

user회원 정보를 저장하기위한 엔티티 입니다.

TimeStamp는 @CreationTimestamp에의해 자동으로 생성됩니다.

변수로는 id,username,password,email,role이 있습니다.

 

CustomUserDetails

package com.example.demo.auth;

import java.util.ArrayList;
import java.util.Collection;

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

import com.example.demo.entity.User;

public class CustomUserDetails implements UserDetails{
	
	
 	private User user;
 	
 	

	public CustomUserDetails(User user) {
 		this.user=user;
 	}
 	
	@Override
	public Collection<? extends GrantedAuthority> getAuthorities() {
		ArrayList<GrantedAuthority> collection=new ArrayList<>();
		collection.add(new GrantedAuthority() {

			@Override
			public String getAuthority() {
				return user.getRole();
			}
			
		});
		return collection;
	}

	public String getEmail() {
		return user.getEmail();
	}
	
	@Override
	public String getPassword() {
		
		return user.getPassword();
	}

	@Override
	public String getUsername() {
		
		return user.getUsername();
	}

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

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

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

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

유저 정보를 저장하는 UserDetails 클래스입니다.

추후 이 클래스가 authentication의 principal에 들어가게 됩니다.

 

CustomUserDetailsService

package com.example.demo.auth;

import org.springframework.beans.factory.annotation.Autowired;
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 com.example.demo.entity.User;
import com.example.demo.repository.UserRepository;

@Service
public class CustomUserDetailsService implements UserDetailsService{

	@Autowired
	private UserRepository repository;
	

	
	@Override
	public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
		User user=repository.findByUsername(username);
		return new CustomUserDetails(user);
	}

}

loadUserByUsername 메서드는 username을 통해 데이터베이스에서 해당 정보를 불러와 UserDetails를 만들어 리턴합니다.

 

CustomAuthenticationProvider

 

package com.example.demo.auth;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;

public class CustomAuthenticationProvider implements AuthenticationProvider{

	@Autowired
	private CustomUserDetailsService service;
	
	@Autowired
	private BCryptPasswordEncoder encoder;
	
	
	@Override
	public Authentication authenticate(Authentication authentication) throws AuthenticationException {
		String username=(String)authentication.getPrincipal();
		String password=(String)authentication.getCredentials();
		
		CustomUserDetails user=(CustomUserDetails) service.loadUserByUsername(username);
		
		String encodedPwd=encoder.encode(password);
		
		if(encoder.matches(password, user.getPassword())) {
			return new UsernamePasswordAuthenticationToken(user,null,user.getAuthorities());
		}
		else {
			System.out.println("에러 발생");
		}
		return null;
	}

	@Override
	public boolean supports(Class<?> authentication) {
		return true;
	}
	


	
}

CustomUserDetailsService로 UserDetails를 불러옵니다

이 값과 loginForm에의해 날아온 데이터를 비교하여 일치한다면 UsernameAuthenticaitonToken을 생성하여 리턴합니다.

 

 


결과

이후 서버를 실행 시켜 회원가입 후 로그인 을 한다면 /user에서 "user"문자열을 확인하실수 있습니다.