Project/토이 프로젝트

[Cinemagram] 로그인(Spring Security - session 생성) - (4)

Lea Hwang 2022. 12. 7. 14:03

저번 포스팅에서 말씀드린 것처럼 유효성 검사에 실패할 시 화면에 Json 코드를 보여주는 게 아닌 간단하게 팝업 처리를 한 뒤 로그인 구현으로 넘어가도록 하겠습니다.

 

팝업 처리는 자바스크립트로 진행할 예정이며 별로 중요한 부분이 아니므로 바로 로그인 구현으로 넘어가셔도 괜찮을 것 같습니다.

 

Popup

package com.photo.util;

public class Popup {

    public static String historyBack(String msg){
        StringBuffer sb = new StringBuffer();
        sb.append("<script>alert('"+msg+"'); history.back();</script>");
        return sb.toString();
    }
}

 Script코드를 짤 때 StringBuffer클래스를 이용했습니다.

 

 

위 코드를 ControllerExceptionHandler에 연결해보겠습니다.

package com.photo.handler;

@RestController
@ControllerAdvice
public class ControllerExceptionHandler {

    @ExceptionHandler(CustomValidationException.class)
    public String validationException(CustomValidationException e){
        return Popup.historyBack(e.getErrors().toString());
    }
}

 

 

이렇게 Script처리를 하면 가입할 때 예를 들어 username을 길게 쓰면 Popup이 뜨게 되고 확인 버튼을 누르면 

다시 회원가입 페이지로 이동하게 됩니다.

[ 정리 ]

ResDto, Script 비교

1. 클라이언트에게 응답만 할 경우 - 단순하게 Script를 사용하는 것이 편리합니다.

2. Ajax통신 할 때는 - 코드를 받아서 처리해야 하므로 ResDto를 사용합니다.

 

 

 

 

로그인

이렇게 회원가입 부분이 끝이 났습니다. 바로 로그인 구현으로 넘어가보겠습니다.

우선 회원가입은 한 건 한 상태입니다. (username : apple)

 

signin.html에서 로그인 input부분 코드에 action과 method를 추가하였습니다.

<form class="login__input" action="/auth/signin" method="POST" >
    <input type="text" name="username" placeholder="유저네임" required="required" />
    <input type="password" name="password" placeholder="비밀번호" required="required" />
    <button>로그인</button>
</form>

 

보통 INSERT 할 때는 POST방식을, SELECT 할 때는 GET방식을 사용한다고 배웠습니다.

그런데 여기서는 왜 POST방식을 사용한 걸까요? 로그인 프로세스는 DB에서 해당 데이터가 있는지 찾는(SELECT) 것이니까 GET방식을 사용해야하는 것 아닐까요?

 

대부분의 경우에는 맞지만, 예외적으로 로그인은 POST을 사용합니다.

username이나 password는 개인정보이므로 GET방식을 채택할 시 주소창에 노출되게 됩니다.(querystring)

따라서 POST방식을 통해 Http Body에 username과 password를 담아 전달해야 합니다.

 

 

그리고 이전 포스팅에서 회원가입 처리는 AuthController의 signup메서드를 구현하여 처리했습니다.

하지만 이번 로그인 관련된 부분은 Spring Security에 위임해서 코드를 몇 줄이라도 줄여보고자 합니다.

❓❗ Spring Security란?
Spring 기반 애플리케이션의 보안(인증과 인가 등)을 담당하는 스프링 하위 프레임워크입니다.

'인증'과 '권한'에 대한 부분을 Filter흐름에 따라 처리하고 있습니다. Filter는 Dispatcher Servlet으로 가기 전에 적용되어 가장 먼저 URL 요청을 받지만, Interceptor는 Dispatcher와 Controller사이에 위치한다는 점에서 차이가 있습니다.

[참고] Filter와 Interceptor관련 포스팅 : https://lealea.tistory.com/141

 

Spring Security에 위임하기 위해서 SecurityConfig파일에 코드 한 줄을 추가하고 CustomUserDetailsService를 생성하겠습니다.

 

1. SecurityConfig 시큐리티 설정 파일

@EnableWebSecurity
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Bean
    public BCryptPasswordEncoder encode() {
        return new BCryptPasswordEncoder();
    }

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

        http.csrf().disable();

        http.authorizeRequests()
            .antMatchers("/","/user/**", "/image/**", "/subscribe/**","/comment/**").authenticated()
            .anyRequest().permitAll()
            .and()
            .formLogin()
            .loginPage("/auth/signin") // GET
            .loginProcessingUrl("/auth/signin") // POST
            .defaultSuccessUrl("/");

    }
}

.loginProcessingUrl("/auth/signin")를 추가했습니다.

: 로그인을 post방식으로 "/auth/signin" 주소로 요청 시 Spring Security가 낚아채서 로그인 프로세스를 진행합니다.

 

 

2. CustomUserDetailsService 생성

@Service
public class CustomUserDetailsService implements UserDetailsService {

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        return null;
    }
}

.loginProcessingUrl("/auth/signin") 요청이 들어오면 Spring Security가 낚아채서 로그인 프로세스를 진행한다고 말씀드렸습니다. 구체적으로는 UserDetailsService가 처리합니다.

 

[로직]

  • 클라이언트가 서버에 POST형식으로 /auth/signin 요청
  • SecurityConfig가 http body에 (입력받은) username, password 담아서
  • CustomUserDetailsService의 loadUserByUsername()가 실행되며 로그인을 진행
    • WHAT? 위에서 '.loginProcessingUrl("/auth/signin") 요청이 들어오면 UserDetailsService가 처리합니다.'라고 했는데 이제 와서 CustomUserDetailsService에서 처리한다고??라는 의문이 들 수 있음
      • 원래 IoC컨테이너에 UserDetailsService가 메모리에 떠있는 상태에서 CustomUserDetailsService가 덮어씀으로써 결과적으로 CustomUserDetailsService가 로그인 처리하는 로직임

 

 

 

loadUserByUsername() 안의 내용을 채워보겠습니다.

loadUserByUsername(String username) 

코드를 보니 매개변수로 username만 받고 있습니다. 그럼 UserRepository에서 usename을 확인하는 코드만 넣으면 될 것 같습니다. (password는 Spring Security에서 알아서 확인해줍니다, 감사합니다..)

 

 

여기서는 여러 방식 중 메서드 이름으로 적절한 JPQL 쿼리가 자동 생성되는 방식인 [Spring Data JPA] 쿼리 메서드 기능을 사용해보고자 합니다. 

 

UserRepository

public interface UserRepository extends JpaRepository<User, Integer> {

    // User오브젝트로 리턴
    User findByUsername(String username);

}

 

CustomUserDetailsService

@RequiredArgsConstructor
@Service
public class CustomUserDetailsService implements UserDetailsService {

    private final UserRepository userRepository;

    // 자동으로 세션을 만듦(CustomUserDetails가 세션에 저장됨 - userEntity을 들고 있음)
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {

        User userEntity = userRepository.findByUsername(username);

        if(userEntity == null) {
            return null;
        }else { 
            return new CustomUserDetails(userEntity);
        }
    }
}

 

 

CustomUserDeails 생성 이유

userEntity가 null이 아닐 경우 최종적으로 세션을 만드는 코드를 작성해야 합니다. (else 부분)

쉽게 쉽게 userEntity를 리턴하려고 했는데, 리턴 타입이 UserDetails이므로 단순히 userEntity로 리턴해서는 안 됩니다.

 

막상 else { } ← 안에 UserDetails를 구현하기에는 생성자, @Override할 코드가 많아져 가독성이 떨어지므로 새롭게 CustomUserDeails class를 생성해 처리하였습니다.

 

 

CustomUserDetails

@Getter
public class CustomUserDetails implements UserDetails { 

    private User user;

    public CustomUserDetails(User user) {
        this.user = user;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        Collection<GrantedAuthority> collection = new ArrayList<>();
        collection.add(()-> "ROLE_"+user.getRole().toString());
        return collection;
    }

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

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

    // 밑에 4가지가 false면 로그인 안됨(현업에서는 회사 정책에 따름)
    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

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

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

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

 

권한을 가져오는 함수

@Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        Collection<GrantedAuthority> collection = new ArrayList<>();
        collection.add(()-> "ROLE_"+user.getRole().toString());
        return collection;
    }

리턴 타입이 Collection이므로 단순히 return을 user.getRole()로 처리할 수 없습니다.

  • 리턴타입이 Collection인 이유는 권한이 여러 개가 존재할 수 있기 때문입니다.
  • ArrayList를 생성한 후 add 해서 최종 return 하는 코드를 구현했습니다. (ArrayList의 부모가 Collection)

 

 

 

여기까지 해서 세션이 자동으로 생성되었습니다.(userEntity 들고 있는 상태)

세션이 생성되었다고 하는데 어디에 있는 것이고 우리는 어떻게 찾아서 사용할 수 있을까요?

 

 

우선 어떤 로직으로 세션이 '어디에' 생성되는지 알아야합니다.

  • 사용자가 /auth/signin을 POST방식으로 요청
  • 시큐리티가 낚아채서 CustomUserDetailsService에 넘김
  • CustomUserDetailsService내부에서 username이 있는지 확인 
    • 없으면, 내보냄
    • 있으면, 리턴한 CustomUserDetails를 세션에 저장, 좀 더 자세히는 CustomUserDetails를 Authentication객체 안에 저장
  • 그래서 세션(session)은 어디에 있나?
    • 일반적으로 session은 key=value 형태로 되어있으나 로그인 후 생성된 이 세션은 세션 영역 → SecurityContextHolder →  Authentication객체 안에 존재합니다.

 

😂 즉, User오브젝트를 찾기 위해서는
Session → SecurityContextHolder → Authentication → CustomUserDetails → User오브젝트를 찾아야 합니다.

 

휴,, 계속 파고 들어가야 User 오브젝트 관련 정보를 얻을 수 있는데요, 시큐리티가 미안했는지 좀 더 간편하게 찾을 수 있는 @AuthenticationPrincipal어노테이션을 제공해주었습니다. (채찍 후 당근 느낌이 이런 걸까요..)

@AuthenticationPrincipal
Authentication객체에 바로 접근이 가능합니다.

 

 

이번 프로젝트에서는 두 가지 모두 다뤄보고 저의 경우는 어떤 것을 사용할지 말씀드리겠습니다.

 

세션에 접근하는 두 가지 방법

실험할 페이지 경로는 user/update입니다. 로그인을 하고 회원정보 변경을 하기 위해서는 세션 정보가 필요합니다.

 

UserController

1. 직접 세션 찾기

@GetMapping("/user/{id}/update")
public String update(@PathVariable int) {
    Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
    CustomUserDetails user = (CustomUserDetails) authentication.getPrincipal();
    String username = authentication.getName();

    System.out.println("직접 찾은 세션정보 - user: " + user.getUser());
    System.out.println("직접 찾은 세션정보 - username: " + username);

    return "user/update";
}


현재 세션 사용자의 객체를 가져오는 코드로

Authentication 객체의 getPrincipal() 메서드를 실행하게 되면, UserDetails를 구현한 사용자 객체가 가지고 있는 정보를 Return 합니다.

Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
CustomUserDetails user = (CustomUserDetails) authentication.getPrincipal();

 

여기서 User에 대한 정보만 출력해보겠습니다.

System.out.println("직접 찾은 세션정보 - user: " + user.getUser());

 

[결과] apple로 로그인한 후 콘솔 확인

직접 찾은 세션정보 - user: User(id=1, username=apple, password=$2a$10$3fXunfexrFSze/qzYpl5AOE914BfeND.aDQpZpggUL8AjpTIekdGS, name=이름은사과, website=null, bio=null, email=apple@apple.com, phone=null, gender=null, profileImageUrl=null, role=ROLE_USER, createDate=2022-11-22T14:20:31.827)
직접 찾은 세션정보 - username: apple

 

[참고] @PathVariable 관련 포스팅
@PathVariable은 데이터를 받아오는 데에 사용합니다.
값을 하나만 받아올 수 있으므로, 쿼리 스트링 등을 이용한 여러 개 데이터를 받아올 때는 @RequestParam을 사용합니다.
https://lealea.tistory.com/162

 

 

 

2. 어노테이션을 이용해 세션 찾기

@GetMapping("/user/{id}/update")
public String update(@PathVariable int id, @AuthenticationPrincipal CustomUserDetails customUserDetails) {

    System.out.println("세션 정보확인: " + customUserDetails.getUser());

    return "user/update";
}

 

 

 

[결과] apple로 로그인 한 후 콘솔 확인

세션 정보확인: User(id=1, username=apple, password=$2a$10$3fXunfexrFSze/qzYpl5AOE914BfeND.aDQpZpggUL8AjpTIekdGS, name=이름은사과, website=null, bio=null, email=apple@apple.com, phone=null, gender=null, profileImageUrl=null, role=ROLE_USER, createDate=2022-11-22T14:20:31.827)

 

 

자! 어떤 게 편하신가요? 전 후자가 간결해서 앞으로는 후자로 쭉 쓸 것 같긴 합니다 ㅎㅎ

만약 추후 후자로 사용할 시 사이드이펙트가 나타난다면 전자로 바꾸거나 다른 방안을 찾아보겠지만 그전까지는 제공해주는 어노테이션을 사용하려 합니다. 

 

 

이제 세션이 어디에 있는지 그래서 어떻게 접근하는지 까지 알아보았습니다. 

다음 포스팅에서는 세션을 이용해서 회원정보 수정을 해보도록 하겠습니다.

 

 

 

 

여기까지 로그인 Spring Security session 생성으로 push 했습니다.