Spring Secuirty 13 나만의 로그인 및 회원가입 만들기 3
포스트
취소

Spring Secuirty 13 나만의 로그인 및 회원가입 만들기 3

소스 주소 : https://gitlab.com/kimdongy1000/spring_security_web/-/tree/main_0903?ref_type=heads

작업 주소 : https://gitlab.com/kimdongy1000/spring_security_web/-/commit/29c0fdd6b94a0c246b7228e38a478af96d38e4c0

지난시간엔 잠깐 CSRF 가 무엇인지만 살펴보았고 계속해서 커스텀 로그인 로직을 계속해서 만들어가겠습니다

login.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <link href="/bootStrap/css/bootstrap.min.css" rel="stylesheet">

    <link href="/resources/css/login.css" rel="stylesheet">

    <title>로그인 페이지</title>
</head>
<body>


<main class="form-signin">
    <form id = "login_form" method="post" action="/login/">
        <h1 class="h3 mb-3 fw-normal"> 로그인 </h1>

        <div class="form-floating">
            <input type="email" class="form-control" id="floatingInput" placeholder="name@example.com" name = "email">
            <label for="floatingInput">Email address</label>
        </div>
        <div class="form-floating">
            <input type="password" class="form-control" id="floatingPassword" placeholder="Password" name = "password">
            <label for="floatingPassword">Password</label>
        </div>

        <input  th:name="${_csrf.parameterName}" th:value="${_csrf.token}">


        <button id = "btn_login" class="w-100 btn btn-lg btn-primary" type="button"> 로그인 </button>
    </form>
</main>

<script src="/bootStrap/js/bootstrap.bundle.min.js"></script>
<script src="/resources/js/login.js"></script>
</body>
</html>

login.css

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
html,
body {
  height: 100%;
}

body {
  display: flex;
  align-items: center;
  padding-top: 40px;
  padding-bottom: 40px;
  background-color: #f5f5f5;
}

.form-signin {
  width: 100%;
  max-width: 330px;
  padding: 15px;
  margin: auto;
}

.form-signin .checkbox {
  font-weight: 400;
}

.form-signin .form-floating:focus-within {
  z-index: 2;
}

.form-signin input[type="email"] {
  margin-bottom: -1px;
  border-bottom-right-radius: 0;
  border-bottom-left-radius: 0;
}

.form-signin input[type="password"] {
  border-top-left-radius: 0;
  border-top-right-radius: 0;
}

.form-signin input[type="text"] {
  margin-top: 20px;
  border-top-left-radius: 0;
  border-top-right-radius: 0;
}

securityConfig

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Bean
public SecurityFilterChain  securityFilterChain(HttpSecurity httpSecurity) throws Exception{

	httpSecurity.authorizeRequests().antMatchers("/" , "/signUp/*" , "/bootStrap/css/**" , "/bootStrap/js/**" , "/resources/css/**","/resources/js/**")
	.permitAll().anyRequest().authenticated();

	httpSecurity.formLogin()
			.loginPage("/login/")
			.loginProcessingUrl("/login/")
			.usernameParameter("email")
			.passwordParameter("password")
			.permitAll();

	return httpSecurity.build();
}

이제 httpSecurity.formLogin() 에서 체인 패턴으로 더 붙었습니다

loginPage 는 어떤 로그인 페이지를 쓸것인지에 대한 핸들러 규정입니다 loginProcessingUrl 로그인이 시작되는 url 주소입니다 그럼 loginPage 랑 햇갈릴 수 있는데 loginPage GET 방식으로 loginProcessingUrl post 방식으로 움직입니다 usernameParameter UsernamePasswordAuthenticationFilter 에서 username 파라미터를 어떤 쿼리 스트링으로 들어오는지 지정합니다 저는 key 값을 email 로 보낼것입니다 passwordParameter UsernamePasswordAuthenticationFilter 에서 password 파라미터를 어떤 쿼리 스트링으로 들어오는지 지정하고 저는 key 값을 password 보낼것입니다

그리고 이때 로그인 form 페이지는 permitAll 로 지정을 합니다 httpSecurity.authorizeRequests().antMatchers("/" , "/signUp/*" , "/bootStrap/css/**" , "/bootStrap/js/**" , "/resources/css/**","/resources/js/**") 이곳에서도 지정 할 수 있습니다

LoginController.java

1
2
3
4
5
6
7
8
9
10
@Controller
@RequestMapping("login")
public class LoginController {

    @GetMapping("/")
    public String loginPage(){

        return "login";
    }
}

로그인 페이지 핸들러 작성

로그인 페이지

CustomAuthenticationManger.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class CustomAuthenticationManger implements AuthenticationProvider {

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        return null;
    }

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


여기서는 이제 로그인해서 넘어오는 데이터를 바탕으로 커스텀 인증 요건을 만들어나갈 예정입니다 이때는 앞에서 넘어오는 ProviderManager 에서 넘어오는 값을 추출해서 저만의 인증 추가로직을 만들어나갈 예정입니다

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Component
public class CustomAuthenticationManger implements AuthenticationProvider {

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {

        System.out.println(authentication);

        return null;
    }

    @Override
    public boolean supports(Class<?> authentication) {
        return UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication);
    }
}

SecurityFilterChain 에 Provider 명시

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Bean
public SecurityFilterChain  securityFilterChain(HttpSecurity httpSecurity) throws Exception{

	httpSecurity.authorizeRequests().antMatchers("/" , "/signUp/*" , "/bootStrap/css/**" , "/bootStrap/js/**" , "/resources/css/**","/resources/js/**").permitAll().anyRequest().authenticated();

	httpSecurity.formLogin()
			.loginPage("/login/")
			.loginProcessingUrl("/login/")
			.usernameParameter("email")
			.passwordParameter("password")
			.permitAll();


	httpSecurity.authenticationProvider(customAuthenticationManger);

	return httpSecurity.build();
}

httpSecurity.authenticationProvider(customAuthenticationManger); 이렇게 함으로서 우리는 이제 커스텀한 Provider 를 사용할 준비가 되어 있는것입니다 우리는 일단 디버깅을 UsernamePasswordAuthenticationFilter 에 하나 걸것이고 ProviderManager 에 하나 걸것입니다

UsernamePasswordAuthenticationFilter

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@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);
}

이곳이 제일 먼저 호출이 됩니다 그럴 수 밖에 없는것은 일단 Filter 는 등록만 해두면 mvc 요청이 왔을때 왠만하면 한번은 다 거치기 때문에 이곳에 안들어 올 순 없습니다 그러면 이 내용들은 다 알지만 한번씩 더 살펴보면

if (this.postOnly && !request.getMethod().equals(“POST”)) 로그인은 반드시 post 요청으로 들어와야 하고 이곳에서 요청정보의 username , password 를 꺼내게 됩니다 그리고 unauthenticated 를 호출해서 미인증토큰만 만들고 이 토큰을 providerManger 에게 요청을 하게 됩니다

ProviderManager

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public class ProviderManager implements AuthenticationManager, MessageSourceAware, InitializingBean 
	public Authentication authenticate(Authentication authentication) throws AuthenticationException {
		
		...

		int currentPosition = 0;
		int size = this.providers.size();
		for (AuthenticationProvider provider : getProviders()) {
			if (!provider.supports(toTest)) {
				continue;
			}

			if (logger.isTraceEnabled()) {
				logger.trace(LogMessage.format("Authenticating request with %s (%d/%d)",
						provider.getClass().getSimpleName(), ++currentPosition, size));
			}

			try {
				result = provider.authenticate(authentication);
			}	

			...
		}

		if (result == null && this.parent != null) {
			try {
				parentResult = this.parent.authenticate(authentication);
				result = parentResult;
			}
			...
		}	
	}

이곳으로 들어오게 되는데 이곳에서 재미있는 일이 생기게 됩니다 int size = this.providers.size(); 이 부분에 오게 되면 인증을 처리하기 위해서 provider 가 제공이 되는데 이게 몇개 있는지 살펴보게 됩니다

1
2
3
4
5
6
7
providers = {ArrayList@13116}  size = 2
 0 = {CustomAuthenticationManger} 
  Class has no fields
 1 = {AnonymousAuthenticationProvider} 
  messages = {MessageSourceAccessor} 
  key = "1e9c230b-cee7-4441-9a3d-11dcbc7f46e5"

이 안에 두개를 살펴보면 아직 아무런 내용이 없는 CustomAuthenticationManger와 AnonymousAuthenticationProvider 이 존재하게 됩니다 AnonymousAuthenticationProvider 같은 경우는 익명사용자 즉 인증이 되지 않은 익명사용자를 인증해서 사용합니다 이에 대해서는 다음에 다룰 기회가 있을예정입니다 우리가 CustomProvider 이 아니라면 CustomAuthenticationManger 아니라 DaoAuthenticationProvider 이쪽으로 호출이 들어오게 됩니다 이 부분을 우리가 정의한 AuthenticationManger 를 호출하게 되는것입니다

CustomAuthenticationManger

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
@Component
public class CustomAuthenticationManger implements AuthenticationProvider {

    @Autowired
    private UserRepository userRepository;

    @Autowired
    private PasswordEncoder passwordEncoder;


    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {

        String username = authentication.getName();

        if(!StringUtils.hasText(username)){
            throw new UsernameNotFoundException("username 이 존재하지 않습니다");
        }

        String password = authentication.getCredentials().toString();

        if(!StringUtils.hasText(password)){
            throw new UsernameNotFoundException("password 가 존재하지 않습니다");
        }

        Optional<UserEntity> opsUserEntity  = userRepository.findByUsername(username);



        if(!opsUserEntity.isPresent()){
            throw new RuntimeException("존재하지 않는 회원입니다");
        }

        UserEntity userEntity = opsUserEntity.get();

        if(!passwordEncoder.matches(password , userEntity.getPassword())){
            throw new RuntimeException("비밀번호가 서로 다릅니다.");
        }

        List<GrantedAuthority> ADMIN_AUTHORITIES = Arrays.asList(new SimpleGrantedAuthority("ADMIN"));

        UserDetails User = new User(userEntity.getUsername() , userEntity.getPassword() ,  ADMIN_AUTHORITIES);

        UsernamePasswordAuthenticationToken authenticationToken = UsernamePasswordAuthenticationToken.authenticated(User , authentication.getCredentials() , ADMIN_AUTHORITIES);


        return authenticationToken;
    }

    @Override
    public boolean supports(Class<?> authentication) {
        return UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication);
    }
}

우리는 DaoAuthenticationProvider 가 아닌 우리가 직접 만든 CustomAuthenticationManger 를 사용하여 인증을 마무리 짓도록 하겠습니다 AuthenticationProvider 구현체로 받아서 구현을 하게 되면 public Authentication authenticate(Authentication authentication) throws AuthenticationException 반드시 구현을 해야겠금 내려오기 때문에 여기에서 우리는 인증을 마무리 해주면됩니다

우리가 가져올 DB 는 JPA 의 UserEntity 에서 가져오는 것임으로 Optional<UserEntity> opsUserEntity = userRepository.findByUsername(username); 데이터를 조회후 간단한 로직만 맞추고 마지막에는

1
2
UsernamePasswordAuthenticationToken authenticationToken = UsernamePasswordAuthenticationToken.authenticated(User , authentication.getCredentials() , ADMIN_AUTHORITIES);

사용해서 토큰객체를 만들어서 return 하면됩니다 그럼 이 토큰객체는 자연스럽게 시큐리티 컨텍스트에 들어가게 됨으로 그 이후 로직은 시큐리티가 알아서 만들어주게 됩니다 그러면 우리는 인증된 객체를 가지고 로그인해서 demo 를 요청하면 우리가 넣은 데이터 그대로 잘 나오는것을 확인할 수 있습니다