본문 바로가기
프로젝트/Recipository

[Dev] 22.11.18. Login Process

by 규글 2022. 11. 18.

 국비 과정에서의 프로젝트를 기반으로 로그인 기능을 구현했었으나, 지난 게시글의 Signin 기능에 이어서 Spring Security를 활용해서 Login 기능을 구현하려고 한다. 다음 그림의 내용을 기반으로 한다.

 

 Tomcat server는 여러 servlet 들을 담고 있어서 Servlet Container라고 한다. DispatcherServlet 은 request url 정보로 등록된 controller의 method를 mapping 해서 찾도록 하는데, request가 들어왔을 때 이 DispatcherServlet 안쪽으로 순서가 들어오기 위해서는 앞단에서 먼저 수많은 Filter들을 통과해서 와야한다. 이 Filter들은 request에 대해 공통으로 동작하게 되는데, 서로 chain 처럼 얽혀있어서 이를 Filter Chain 이라고 한다.

 이 Filter 들 중에서 Security Filter Chain의 역할을 하는 친구들도 있을텐데, 사이트의 정책에 따라 여러 Security가 존재할 수 있기때문에 이들을 유연하게 교체할 수 있도록 Proxy 구조로 형성되어 있다. Filter가 위치할 자리에는 Filter Chain의 대리자가 들어가게 되고, 이 Security Filter Chain은 요청하는 url에 따라 다르게도 가능하다.

 

 우선 검정 line을 따라가면서 보면 좋겠다.

 

 Security Filter Chain 들의 Filter는 사이트에 접속하는 계정에 대한 인증에 관여하며, Authentication을 제공한다. 이 Authentication은 Authentication Manager를 통해서 Authentication 에 인증을 받은 후, Security Context Holder에 넣어주는 역할을 한다.

 이 인증을 받는 Authentication 을 implements 한 객체들은 일반적으로 Token 이라는 이름으로 구현된다. Token은 일종의 통행증으로, 어떤 인증에 대한 허가인지 Provider Manager에 알려주기 위해 support( ) method를 제공한다. 이 method에는 여러 개가 올 수 있다.

 

 즉 인증 요청이 왔을 때 인증을 위해서는 Authentication, 즉 어떤 token인지에 따라 ProviderManager에서 Authentication Provider를 결정(support( ) method)해서 이어준다. 인증이 된다면 principal에 인증 결과를 담아 return 해준다.

 

 이제부터는 빨간 line을 따라가면서 보면 좋겠다.

 

 만약 Authentication Provider를 customizing 하고 싶다면 implements 해서 만든 뒤 Security Configuration에 주입해서 AuthenticationManagerBuilder 에 대한 configuration에 등록해주면 된다. 하지만 Authentication이나 Authentication Provider를 직접 개발하는 상황은 많지 않다. 만약 개발자가 직접 정의해여 개발하는 것이 아니라면 AuthenticationManager Factory Bean 에서 DaoAuthentication Provider 를 기본 인증 제공자로 등록한다고 한다. 이때, 이 친구는 반드시 하나 이상의 UserDetailsService를 발견할 수 있어야 한다.

 

 이 경우 UserDetails를 implements 한 SpUser 객체를 만든다. 이 객체는 인증을 받는 principal의 역할을 하며, userId, email, password, authorities 등 여타 계정에 대한 정보를 가진다. 여기서 중요한 점은 인증을 받을 때 username과 password를 기반으로 하기때문에, override 되는 username 에 대한 method를 조금 수정해주어야 올바르게 동작한다.

 그리고 가장 중요한 UserDetailsService를 implements 한 SpUserService를 만든다. 이 객체는 loadUserByUsername() method를 override 하는데, 여기에 JPA Repository를 연동한다. 이렇게 만든 친구를 SecurityConfig 객체에 주입하여 AuthenticationManagerBuilder에 대한 configure method에 추가하면 된다. 이때 loadUserByUsername( ) methoid를 사용하므로 이 Service는 곧 Provider Manager의 역할을 하는 바, 때문에 Authentication Manager로 관리되도록 한다고 이해하면 좋을 것 같다.

 

 위 내용들을 기반으로 로그인 기능에 대한 작업을 해보았다. 기존에 작업했던 내용은 주석으로 처리하거나 지워버렸다.

 

로그인 Process

 과정은 다음과 같다.

  • 로그인은 Spring Security를 따른다.
    • UserDetailsService를 implements 한 SpUserService를 만들어서 loadUserByUsername method를 override 한다.
    • SpUserService를 Config에 inject 해서 AuthenticationManager에 추가해준다.
  • 로그인에 성공했을 경우 별다른 메시지를 띄우지 않고 메인 페이지로 넘긴다.
  • 로그인에 실패했을 경우 실패 메시지를 client에 띄우고 다시 로그인 페이지로 넘긴다.

 

 지난 작업에서 이미 UserDetails를 implements 한 SpUser 는 만들었으므로, 이번에는 UserDetailsService를 implements 한 SpUserService 부터 만들었다.

 

UserRepository.java (Interface)

package com.example.recipository.repository;

import com.example.recipository.domain.SpUser;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

public interface UserRepository extends JpaRepository<SpUser, Long> {
    public boolean existsByEmail(String email);
    public boolean existsByName(String name);
    public SpUser save(SpUser user);
    public Optional<SpUser> findSpUserByEmail(String email);
}

 DB의 email column에 data가 있는지에 대한 method를 만들어주었다.

 

 

SpUserService.java

package com.example.recipository.service;

import com.example.recipository.repository.UserRepository;
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;

@Service
public class SpUserService implements UserDetailsService {
    private final UserRepository userRepository;

    public SpUserService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        return userRepository.findSpUserByEmail(username)
                .orElseThrow(() -> new UsernameNotFoundException(username));
    }
}

 UserDetalsService를 implements 하고 loadUserByUsername( ) method를 override 했다. 그리고 UserRepository 에 추가해주었던 DB에서 email 정보를 찾는 method를 통해 UserDetails 객체를 return 하도록 했다. 이제 이렇게 만든 UserDetailsService 객체를 SecurityConfig 에 주입해줄 것이다.

 

 

SpringSecurityConfig.java

package com.example.recipository.config;

import com.example.recipository.service.SpUserService;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
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.builders.WebSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;

@Configuration
@EnableWebSecurity(debug = true)
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {
    private final SpUserService spUserService;

    public SpringSecurityConfig(SpUserService spUserService) {
        this.spUserService = spUserService;
    }

    @Bean
    PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(spUserService);
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .authorizeRequests(request ->
                        request.antMatchers("/", "/signinform", "/signin", "/duplcheck").permitAll()
                                .anyRequest().authenticated()
                )
                .formLogin(login ->
                        login.loginPage("/loginform").permitAll()
                                .loginProcessingUrl("/login").permitAll()
                                .defaultSuccessUrl("/", false)
                                .failureUrl("/login-failure")
                );
    }

    @Override
    public void configure(WebSecurity web) throws Exception {
        web
                .ignoring()
                .antMatchers("/app/**", "/lib/**");
    }
}

 UserDetailsService 객체를 SecurityConfig 에 주입하고, AuthenticationManagerBuilder에 대한 configure method에서 추가해주었다. 그리고 PasswordEncoder를 BCryptPasswordEncoder 로 return 하도록 @Bean 으로 주입해준다. 어떤 블로그에서는 configure method 안의 UserDetailsService를 등록하고 password Encoder까지 등록해준 내용을 보았는데, 굳이 그렇게 하지 않아도 BCryptPasswordEncoder를 이용하게 되므로 하지 않아도 된다.

 

 Debug 모드로 쭉 인증 과정을 따라가보면 DaoAuthenticationProvider 로 오게 되는데, 여기에서 사용하는 PasswordEncoder를 SecurityConfig에서 @Bean 으로 추가한 BCryptPasswordEncoder 로 하는 것이다. 만약 따로 등록하지 않는다면 DelagatingPasswordEncoder 로 설정되는 것을 보았다.

 

 단순히 loginProcessUrl을 지정하고,  성공과 실패 시 이동할 url을 지정했을 경우에 따로 볼 수 있는 메시지는 존재하지 않는다. 우선 로그인 성공 시 메시지는 띄우지 않을 것이다. 사실은 기본적으로 root 페이지로 돌아가게 되어있어서 필요한 것은 아니지만, 후에 다른 페이지에서 권한을 요구하여 로그인 페이지로 넘어왔을 때에도 root로 넘어가는 것은 좋지 못하기 때문에 alwaysUse 옵션은 false로 설정했다.

 실패 시 이동할 url에서 문제를 확인하고 로그인 페이지로 다시 돌아가는 과정을 손으로 하게 된다면 조금은 번거로울 것이다. 따라서 필자는 실패 시 이동할 페이지에 따로 화면을 구성하지 않고, alert를 통해 메시지와 함께 자동으로 로그인 페이지로 이동할 수 있도록 할 것이다.

 

 

View Page

PageController.java

    @GetMapping("/login-failure")
    public String goLoginFailure(){
        return "pages/login-failure";
    }

 View page를 위한 PageController 를 작성한 것이다.

 

 

loginform.html

<!DOCTYPE html>
<html lang="en"
      xmlns="http://www.w3.org/1999/xhtml"
      xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <meta name="_csrf" th:content="${_csrf.token}">
    <meta name="_csrf_header" th:content="${_csrf.headerName}">
    <title>loginform.html</title>
</head>
<body>
    <div class="container">
        <a href="/" th:href="@{/}">
            <img src="/lib/image/logo.png" alt="">
        </a>
        <form th:action="@{/login}" method="post" id="loginForm">
            <label for="username">Email</label>
            <input type="text" id="username" name="username" required="required">
            <br>
            <label for="password">Password</label>
            <input type="text" id="password" name="password" required="required">
            <br>
            <button type="submit">로그인</button>
        </form>
    </div>
</body>
</html>

 로그인 form 이 있는 view page의 구성이다. 여기에서 중요한 점은 다음의 두 가지이다.

  • 로그인을 위한 id와 password input의 name을 각각 username 과 password 로 해야한다. 이는 로그인을 위한 UsernamePasswordAuthenticationFilter에서 data를 username과 password 로 받고 있기 때문이다.
  • Form의 action은 SecurityConfig의 loginProcessUrl 과 동일하게 작성해야 한다.

 필자의 경우 form의 data를 UserDetails로 받는 것이라고 착각하여 처음에 input의 name을 username이 아니라 email로 했다가 오류에 직면했었다. 오류를 인지하고 수정했다.

 

 

login-failure.html / login-failure.js

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>login-failure.html</title>
</head>
<body>

</body>
<script src="/app/login-failure.js"></script>
</html>

---------------------------------------------------------------------

    window.onload = function(){
        var msg = "입력한 이메일 주소나 비밀번호가 올바르지 않습니다.";
        alert(msg)
        location.href = "/loginform";
    }

 로그인에 실패했을 때 alert를 띄워줄 페이지를 그냥 만든 것이다. 실패 메시지를 띄우고 다시 loginform 페이지로 이동하도록 한다.

 

 

 이렇게 로그인 기능을 만들었다. 앞으로는 이를 시작으로 계획했던 페이지를 하나씩 만들어나가겠다.

댓글