Loading...

Spring/Tistory / / 2022. 4. 13. 09:14

블로그-V3. 시큐리티 로그인 프로세스

반응형

 

1. 스프링 시큐리티는 /login (POST) 주소로 요청이 들어오면 로그인 프로세스가 자동 진행된다.

 

/login은 정해진 주소이고, GET 요청은 안 받아준다!!

 

2. 스프링 시큐리티는 /login 요청이 올 때 Body를 확인하는데, x-www-form-urlencoded만 확인한다.

 

즉, Json 테이터는 받지 않는다!

 

3. 1번과 2번을 BasicAuthenticationFilter가 진행하여

클라이언트의 요청을 받아 x-www-form-urlencoded 타입을 파싱 해주는데

 

두 가지 키 값만 체킹 한다.

 

usernamepassword!!

 

우리 DB에서 username을 user, password를 pw라고 지정해주면 시큐리티가 인식하지 못한다.

이때 변경하고 싶다면 SecurityConfig에서 변경 가능하다.

 

@Override
protected void configure(HttpSecurity http) throws Exception {

    // super.configure(http); 부모 메서드 안쓸거야!!
    http.csrf().disable(); // 이거 안하면 postman 테스트 못함
    http.authorizeRequests()
            .antMatchers("/s/**").authenticated()
            .anyRequest().permitAll()
            .and()
            .formLogin()
            // .usernameParameter("user")
            // .passwordParameter("pw")
            .loginPage("/login-form")
            .loginProcessingUrl("/login") // login 프로세스를 탄다
            .defaultSuccessUrl("/");
}

 

 

String username = "ssar";

String password = "1234";

두 개의 키값을 확인하여 값이 들어오는데 까지 BasicAuthenticationFilter가 진행한다.

 

그 다음 UsernamePasswordAuthenticationFilter 필터로 넘어간다.

 

username과 password로 로그인 요청을 할 때 DB에 확인하고 인증 체크하는 필터이다.

 

(1) Token을 생성한다.

 

UsernamepasswordAuthenticationToken을 만들어준다.

이 클래스의 필드로 "ssar", "1234"를 가지고 있는 토큰을 생성한다.

 

쉽게 말해 변수 두 개를 클래스에 넣은 건데

사용자가 던진 값으로 인증을 위한 토큰을 생성한 것이다.

 

(2) 이 토큰으로 DB에 질의한다.

 

이 필터가 질의하기 위해 loadUserByUsername( )을 호출하는데 이 메서드 내부가 비어있다.

시큐리티가 만든 DB가 아니기 때문에 여기까지 스프링이 만들어줄 수는 없다.

 

내부는 내가 직접 짜야한다.

대신 리턴 타입은 UserDetails로 맞춰줘야 한다.

 

내부 코드에서 DB에 username으로 질의해서 데이터가 있나 확인한다.

 

UserDetails loadByUsername(String username) {
	// DB에 username이 있는지 질의
    return new UserDetails();
}

 

왜 user가 아니라 userDetails일까?

 

user는 내 마음대로 만든 오브젝트인데

시큐리티에 필요한 기본적인 필드들이 있기 때문에 userDetails로 만들어야 한다.

 

3. 시큐리티 세션에 담긴다.

 

시큐리티 세션은 우리가 만든 세션과는 다르게 깊게 들어가 있다.

 

SecurityContextHolder안에, Authentication안에,  Principal에 userdetails가 담긴다.

이는 getPrincipal( )로 호출이 가능하다.

 

SecurityContextHolder.Authentication.getCredentials는 비밀번호가 나오고,

SecurityContextHolder.Authentication.getAuthorities는 권한이 나온다.

 

왜 시큐리티가 세션의 구조를 만들어 두었을까?

 

보안을 위해서는 기본적으로 있어야 하는 필드들이 있다.

 

내가 만든 user에는 이런 필드가 없기 때문에 시큐리티에서 강제성을 부여한 것이다.

 

그래서 loadUserByUsername( ) 메서드에서 userdetails를 리턴해주면 Principal에 담기게 된다.

 

이제 머스태치에서 세션의 principal을 찾는다고 찾을 수 없다.

저장되어있는 위치가 다르기 때문에!!

 

머스태치에서 사용하기 위해서는 설정이 필요하다.

 

우리만의 어노테이션을 만들어 줄 것이다.

 

여기까지 핵심정리

로그인 프로세스를 시큐리티가 처리해주는 이유는

내가 직접 하면 보안에 필요한 기본적인 오브젝트를 세션에 넣지 않기 때문에,

시큐리티가 대신해서 보안에 필요한 오브젝트를 만드는 강제성을 부여해주는 것이다.

 

이제 userdetails 코드를 커스터마이징 해서 만들어주고,

loadUserByUsername( ) 내부만 구현해주면 된다.

 

 


 

파일을 만들어준다.

 

 

LoginService부터 만들어보자.

UserDetailService 인터페이스를 걸어준다.

 

package site.metacoding.blogv3.config.auth;

public class LoginService implements UserDetailsService {

}

 

메서드를 구현하기 위해 오버라이드 해보면

loadUserByUsername( )만 강제성이 있는 메서드이기 때문에 체크가 되어있다.

 

 

아직 메모리에 뜬 상태는 아니기 때문에 @Service로 메모리에 띄우자.

그러면 IoC 컨테이너에 등록된다.

 

메모리에 떠있기 때문에 UsernamePasswordAuthenticationFilter 필터가

IoC 컨테이너에서 UserDetailsService 타입을 찾아서 loadUserByUsername( ) 메서드를 호출해준다.

 

그래서 인터페이스를 걸어주어서 강제성을 부여하는 것이다.

 

이때 토큰에 있는 두 가지 값 중에 username만 loadUserByUsername( )에게 넘겨준다.

 

password는 필터가 내부적으로 DB에 확인해서 password가 맞나 직접 확인한다.

혹시 모를 공격에 대비해 password에 접근도 못하게 하는 것이다.

 

 

이 username으로 DB에 user가 있는지 질의하고

user가 있으면 영속성 컨텍스트에 user가 저장된다.

그 다음에 내부적으로 시큐리티가 password로 영속성 컨텍스트에있는 user의 password와 비교한다.

 

password는 우리가 손도 못 대게 한다!

 

우리는 영속성 컨텍스트에 user를 넣어주기만 하면 된다.

 

DB에 두 번 질의하지 않는다!

 

package site.metacoding.blogv3.config.auth;

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 // IoC 컨테이너에 등록됨
public class LoginService implements UserDetailsService {

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        System.out.println("username : " + username);
        System.out.println("DB에 확인 들어간다~~");

        return null; // Principal에 null이 들어간다 -> 인증 실패
    }
}

 

IoC에 등록만 하면 여기 파일을 탄다는 게 신기하다!

 

 

이제 DB에 username이 있는지 질의해주자.

 

@Query(value = "SELECT * FROM user WHERE username = :username", nativeQuery = true)
Optional<User> findByUsername(@Param("username") String username);

 

@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
    System.out.println("username : " + username);
    System.out.println("DB에 확인 들어간다~~");

    // user가 DB에 있으면 리턴!
    Optional<User> userOp = userRepository.findByUsername(username);

    if (userOp.isPresent()) {
        return new LoginUser(userOp.get()); // user만 뽑아서 세션에 넣어줄거야
    } // else {
     // 여기 어차피 타지도 않고 터져버린다!! 내가 제어 못함
     // throw new CustomException("유저네임 없어");
     // }
    return null;
}

 

UserDetails는 인터페이스라서 바로 리턴하기 위해서는 익명 클래스가 만들어진다.

이 클래스 내부에 구현해야 할 메서드가 너무 많기 때문에 따로 UserDetails 클래스를 만들어주자.

 

근데 LoginUser는 값이 따로 있는 게 아닌 수많은 메서드들만 가지고 있는데

이게 세션에 담기면 안 될 것 같다.

 

User를 컴포지션 해주자.

 

LoginService에서 LoginUser를 호출할 때 DB에서 가져온 userEntity를 담아줘야 한다.

그 user 정보를 UserDetails에 넣어서 세션에 담으면 되겠다.

 

1. getPassword( )

 

우리는 userDetails가 담긴 세션에서 password를 꺼내 쓸 때

userDetails.getUser( ).getPassword( )로 사용하면 되는데

userDetails의 password 값을 직접 넣어주는 이유는 시큐리티에게 필요하기 때문이다.

이 password로 시큐리티가 보안 작업을 한다.

 

2. isAccountNonExpired( ) : 계정이 만료되지 않았지?

3. isAccountNonLocked( ) : 계정에 락 안 걸렸지?

4. isCredentialsNonExpired( ) : 비밀번호 만료 안됐지?

5. isEnabled( ) : 계정 비활성화 아니지?

 

2, 3, 4, 5번 메서드를 시큐리티가 로그인할 때 다 호출해보기 때문에

하나라도 false가 떨어지면 로그인을 할 수 없다.

다 true로 바꿔주자.

 

package site.metacoding.blogv3.config.auth;

import java.util.Collection;

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

import lombok.Data;
import lombok.RequiredArgsConstructor;
import site.metacoding.blogv3.domain.user.User;

@Data
@RequiredArgsConstructor
public class LoginUser implements UserDetails {

    private final User user;

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        // 권한 로직
        return null;
    }

    @Override
    public String getPassword() {
        // 우리는 userDetail.getUser.getPassword 하면 되는데 우리가 아닌 시큐리티에게 필요
        return user.getPassword();
    }

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

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

    @Override
    public boolean isAccountNonLocked() {
        return true; // false이면 락걸림!!
    }

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

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

 


 

GlobalException 처리를 하려면 스프링 컨테이너 내부에 있어야 하는데,

아직 밖에 있는 필터에 있기 때문에 처리가 안된다.

 

SecurityConfig에서 예외처리 해줘야한다.

 

로그인에 성공하고 실패했을 때 로직은 SecurityConfig 파일에서 처리해준다.

실패했을 때 기본 로직은 history.back( ) 해준다.

 

http.csrf().disable(); // 이거 안하면 postman 테스트 못함
http.authorizeRequests()
        .antMatchers("/s/**").authenticated()
        .anyRequest().permitAll()
        .and()
        .formLogin()
        // .usernameParameter("user")
        // .passwordParameter("pw")
        .loginPage("/login-form")
        .loginProcessingUrl("/login") // login 프로세스를 탄다
        .defaultSuccessUrl("/")
        .failureHandler(new AuthenticationFailureHandler() {

            @Override
            public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
                    AuthenticationException exception) throws IOException, ServletException {
                System.out.println("아 로그인 실패했네 ㅋㅋ");
                response.sendRedirect("/");
            }
        });

 

근데 그냥 실패 로직 만들지 말고 history.back( ) 해주는 디폴트로 가자!

 


 

이제 메인 컨트롤러에서 세션에 접근해보자.

@AuthenticationPrincipal이라는 어노테이션으로 접근이 가능하다.

 

@GetMapping({ "/" })
public String main(@AuthenticationPrincipal LoginUser loginUser) { // 시큐리티의 세션에 바로 접근
    System.out.println(loginUser.getUsername());
    System.out.println(loginUser.getUser().getUsername());

    LoginUser lu = (loginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); // UserDetails
    System.out.println(lu.getUser().getEmail());

    return "main";
}

 

 

 

 

[출처]

 

https://cafe.naver.com/metacoding

 

메타코딩 : 네이버 카페

코린이들의 궁금증

cafe.naver.com

메타 코딩 유튜브

https://www.youtube.com/c/%EB%A9%94%ED%83%80%EC%BD%94%EB%94%A9

 

메타코딩

문의사항 : getinthere@naver.com 인스타그램 : https://www.instagram.com/meta4pm 깃헙 : https://github.com/codingspecialist 유료강좌 : https://www.easyupclass.com

www.youtube.com

 

 

 

 

 

반응형