해당 POST 보고 오시길 바랍니다.
0. 초기 SecurityConfig 세팅
1) SecurityConfig
@Configuration // IoC 빈(bean)을 등록
@EnableWebSecurity // 필터 체인 관리 시작 어노테이션
@RequiredArgsConstructor
public class SecurityConfig {
private final AuthenticationConfiguration authenticationConfiguration;
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {
return configuration.getAuthenticationManager();
}
@Bean
public BCryptPasswordEncoder encodePwd() {
return new BCryptPasswordEncoder();
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
// cors 비활성화
http
.cors(cors -> cors
.configurationSource(CorsConfig.apiConfigurationSource()));
// csrf disable
http
.csrf(AbstractHttpConfigurer::disable);
// form 로그인 방식 disable
http
.formLogin(AbstractHttpConfigurer::disable);
// http basic 인증 방식 disable
http
.httpBasic(AbstractHttpConfigurer::disable);
// Session을 사용하지 않고, Stateless 서버를 만듬.
http
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
// 경로별 인가
http.
authorizeHttpRequests(authorizeRequests ->
authorizeRequests
.requestMatchers("/user/**","/reissue").authenticated()
.requestMatchers("/manager/**").hasAnyRole("ADMIN", "MANAGE")
.requestMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().permitAll()
);
return http.build();
}
}
- 어노테이션 설명
- @Configuration : IoC 빈(bean)을 등록
- @EnableWebSecurity : 필터 체인 관리 시작 어노테이션
1. 만들어야 할 코드
이전 시간에 Test 용 Filter를 만들고 대충 토큰을 사용하여, 어떤식으로 처리할 건지 보았다.
이제 실제 코드를 만들어 보아야 하는데...
- 처리해야 할 작업
- {Token : secret} 라는 것을 만들어야 된다!
- 언제? id / pw 정상적으로 들어와서 로그인이 완료되면, 토큰을 만들어주고 그걸 응답해준다.
- 요청할 때마다 header에 Authorizaition에 vaule 값으로 토큰을 가지고 올 것이다.
- 그 때, 토큰이 넘어오면 이 토큰이 내가 만든 토큰이 만든지만 검증해주면 된다. ( RSA, HS256 )
-> 작성해보자.
2. User 엔티티 구현
@Data
@Entity
public class User {
@Id // primary key
@GeneratedValue(strategy = GenerationType.IDENTITY)
private int id;
private String username;
private String password;
private String email;
private String role; //ROLE_USER, ROLE_ADMIN
@CreationTimestamp
private Timestamp createDate;
public List<String> getRoleList() {
if(!this.role.isEmpty()) {
return Arrays.asList(this.role.split(","));
}
return new ArrayList<>();
}
}
3. UserDetails 구현 -> PrincipalDetails
// Security가 /login 주소 요청이 오면, 낚아채서 로그인 진행
// 로그인 완료 후 Security Session을 만들어준다! ("Security ContextHolder"에 Session을 저장.)
// 오브젝트 -> Authentication 타입의 객체
// Authentication 안에 User 정보가 있어야 한다.
// User 오브젝트 타입 -> UserDetails 타입의 객체
// Security Session -> Authentication -> User Detatils (Principal Details)
@Getter
public class PrincipalDetails implements UserDetails {
private User user;
public PrincipalDetails(User user) {
this.user = user;
}
// 해당 User의 권한을 리턴 하는 곳
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return user.getRoleList().stream()
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
}
@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() {
// 사이트에서 1년 동안 회원이 로그인을 안하면 -> 휴면 계정으로 전환하는 로직이 있다고 치자
// user entity의 field에 "Timestamp loginDate"를 하나 만들어주고
// (현재 시간 - loginDate) > 1년 -> return false; 로 설정
return true;
}
}
4. UserDetailsService 구현
// login 요청이 오면 자동으로 UserDetailsService 타입으로 IoC 되어 있는 loadUserByUsername 함수가 실행된다.
@Service
@RequiredArgsConstructor
public class PrincipalDetailsService implements UserDetailsService {
private UserRepository userRepository;
@Autowired
public PrincipalDetailsService(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
Optional<User> userEntity = userRepository.findByUsername(username);
if (userEntity.isPresent()) {
User user = userEntity.get();
return new PrincipalDetails(user.getUsername(),user.getPassword(), user.getRole());
}
throw new UsernameNotFoundException("User not found with username: " + username);
}
}
- 이유 : formLogin 꺼놔서, http://localhost:8080/login -> 여기서 UsernamePasswordAuthenticationFilter 작동 안함..
해당 필터의 역할 : /login 요청해서 username,password 전송하면 (POST) 작동한다.
- UsernamePasswordAuthenticationFilter 구현체를 하나 작성하고 ( JwtAuthenticationFilter )
- JwtAuthenticationFilter를 Security Filter에 등록해줘야함.
[ 필터를 상속받아서 커스터마이징한 뒤, 다시 등록해준다! ]
조금 더 친절하게 설명해보겠다!
UsernamePasswordAuthenticationFilter는 Spring Security에서 제공하는 기본적인 인증 필터이다.
사용자의 로그인 인증을 처리하는 역할을 한다.
주로 사용자가 /login 엔드포인트로 POST 요청을 보내면, 해당 필터가 동작하여 사용자의 인증을 시도한다.
(하지만 우리는 필요에 따라 Filter들을 커스터마이징 할 수 있어야 한다.)
우리는 SecurityConfig에서 formLogin 설정이 꺼놓았다.
formLogin은 주로 사용자가 로그인한 경우에 필요한 설정을 담당한다.
이 설정이 비활성화되어 있으면, 기본적으로 /login 엔드포인트에서의 로그인 처리가 이루어지지 않는다.
대신, 우리는 JWT 기반의 사용자 인증을 사용하고자 한다!
이전에 배웠듯, JWT에서 보통 사용자의 인증 정보는 토큰에 포함되어 있으며,
UsernamePasswordAuthenticationFilter 대신에 사용자 정의된 JwtAuthenticationFilter를 구현하여 인증을 처리하게 된다.
해당 필터는 토큰을 추출하고, 토큰이 유효한 경우에 사용자를 인증하도록 동작한다.
-> 이후 해당 필터를 SecurityConfig에 등록!!!! (.addFilter 로)
-> 달아 놓은 필터에 .setFilterProcessesUrl("/login"); (/login 엔드포인트에서의 로그인 처리 이루어지도록)
5. JwtAuthenticationFilter 구현
5-0. UsernamePasswordAuthenticationFilter 커스터마이징 하기
우리는 인증(로그인) 과정에서 JwtAuthenticationFilter를 실행시킬 것이고!
이 필터는 UsernamePasswordAuthenticationFilter를 상속하여 만들것이다.
UsernamePasswordAuthenticationFilter에 들어가보자
이는 또, AbstractAuthenticationProcessingFilter를 상속하고 있다.
다시... AbstractAuthenticationProcessingFilter로 들어가보자...
여기에는 attemptAuthentication, successfulAuthentication, unsuccessfulAuthentication 메소드 등이 있다!
또한, UsernamePasswordAuthenticationFilter에서는 attemptAuthentication 를 ovaerride하여 구현하고 있다!
5-1. UsernamePasswordAuthenticationFilter 동작과정
다시 설명하자면,
- POST "/login"
- 로그인 요청이 오면,
(원래) UsernamePasswordAuthenticationFilter가 가로채서 attemptAuthentication 메소드를 호출한다!
- UsernamePasswordAuthenticationFilter 대신
사용자 정의된 JwtAuthenticationFilter를 구현!
내부 메소드인 attemptAuthentication도 오버라이딩하여 구현! 할 것이다.
원래 Adapter 방식을 사용할 때는,
상속받은 WebSecurityConfigureAdpater에 AuthenticationManager 가 포함되어 있었는데,
FilterChain 방식 부터는 AuthenticationManager를 직접 Bean으로 등록하는 방법으로 변경되었다.
<0. Security Config에서 구현해놓았다.>
- /login 요청을 하면, 로그인 시도를 위해서 실행되는 함수
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
private AuthenticationManager authenticationManager;
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
// 구현해보자
return super.attemptAuthentication(request, response);
}
}
- attemptAuthentication 세부 구현 순서
1) username, password 받아서
2) 정상인지 로그인 시도를 해본다.
받아온 authenticationManager로 로그인을 시도하면 -> UserDetailsService가 호출.
결론적으로 UserDetailsService의 loadUserByUsername()이 호출되게 된다.
3) UserDetails를 세션에 담고
4) JWT 토큰을 만들어서 응답해주면 된다.
근데 굳이 UserDetails를 세션에 담는 이유...?
-> SecurityFilterChain의 // 경로별 인가 (권한관리) 를 하기 위해서!
경로별 인가의 .authenticated() 메소드 또한 이러한 정보를 토대로 이루어진다.
(권한 관리가 필요 없는 경우 3번을 만들 필요가 없다.)
.hasRole()이나 .hasAnyRole() 메서드는 현재 사용자가 특정 권한을 가지고 있는지 여부를 확인한다!
5-2. attemptAuthentication 구현
UsernamePasswordAuthenticationFilter의
attemptAuthentication 메소드를 오버라이딩 하여 로그인 인증 로직을 구현해보자!
-> 위의 4단계를 구현해보자.
로그인 시도를 하면 내부 메소드인 attemptAuthentication가 실행된다고 했다! (우리가 구현해야 할 메소드)
이 메소드의 파라미터 request에 담긴, username과 password를 받아보자!
1) username과 password 받기
🤔 일단, request에 username, password 정보가 담겨 오는 게 맞는가? 어떤 형식으로 오는가?
체크해보자!!
- 가장 원시적인 방법으로 BufferedReader로 확인해보자..
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
// /login 요청을 하면, 로그인 시도를 위해서 실행되는 함수
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
throws AuthenticationException {
System.out.println("JwtAuthenticationFilter : 로그인 시도 중");
// 1. username, password 받아서
try {
BufferedReader br = request.getReader();
String input = null;
while((input = br.readLine()) != null) {
System.out.println(input);
}
} catch (IOException e) {
throw new RuntimeException(e);
}
}
해당 형식으로 request 내에 정보가 담겨오는 것을 알 수 있다!!
html폼을 통해 사용자가 입력한 데이터는 기본적으로 x-www-form-urlencoded 형식으로 날아온다.
요즘에는 Json 형식으로 통일해서 많이 보낸다.
이제부터 Json으로 보낸다고 가정!
-> Json을 Parsing 하는 방법은 간단!
Mapper 사용하면 된다!
public record LoginRequestDto(
String username,
String password
) {
}
// Test
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
throws AuthenticationException {
System.out.println("JwtAuthenticationFilter : 로그인 시도 중");
// 1. request에 있는 username과 password를 파싱해서 자바 Object로 받기
ObjectMapper om = new ObjectMapper();
LoginRequestDto loginRequestDto;
try {
loginRequestDto = om.readValue(request.getInputStream(), LoginRequestDto.class);
} catch (IOException e) {
throw new AuthenticationServiceException("Error of request body.");
}
}
2) 정상인지 로그인 시도를 해본다.
받아온 authenticationManager로 로그인을 시도하면 -> UserDetailsService가 호출.
결론적으로 UserDetailsService의 loadUserByUsername()이 호출되게 된다.
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
throws AuthenticationException {
log.info("JwtAuthenticationFilter : 로그인 시도 중");
// request에 있는 username과 password를 파싱해서 자바 Object로 받기
ObjectMapper om = new ObjectMapper();
LoginRequestDto loginRequestDto;
try {
loginRequestDto = om.readValue(request.getInputStream(), LoginRequestDto.class);
} catch (IOException e) {
throw new AuthenticationServiceException("Error of request body.");
}
// 유저네임패스워드 토큰 생성
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(
loginRequestDto.username(),
loginRequestDto.password());
// PrincipalDetailsService의 loadUserByUsername() 함수가 실행되고 정상이면 authentication이 return됨.
// Token 넣어서 던져서 인증 끝나면 authentication을 주고, 로그인 한 정보가 담긴다.
// DB에 있는 username과 password가 일치한다는 뜻
return authenticationManager.authenticate(authenticationToken);
}
여기서 인증이 완료되면 -> successfulAuthentication
여기서 인증이 실패하면 -> unsuccessfulAuthentication
authenticationManager을 통해. Token을 던져줄 것이다.
(내부 로직은 생략하겠다.)
Manager가 실행되면, PricipalDetailsService의 loadUserByUsername() 함수가 실행되고,
인증이 끝나면 authentication을 뱉어준다.
이 authentication에는 로그인 한 정보가 담겨있다.
[중간정리]
1. attemptAuthentication 메서드는 사용자가 로그인을 시도할 때 호출!
이 메서드에서는 클라이언트가 제공한 usernmae과 password를 가져와서 실제로 인증을 시도하는 과정을 처리한다.
2. ObjectMapper를 사용하여 HTTP 요청의 body에서 JSON 형식으로 전송된 사용자 정보를 읽어온다.
여기서는 username과 password를 포함한 User 객체를 가져온다.
3. 읽어온 사용자 정보를 기반으로 UsernamePasswordAuthenticationToken을 생성한다.
이 토큰에는 인증 과정에서 실제로 사용될 사용자 username과 password를 포함한다.
4. 생성된 토큰을 AuthenticationManager에 전달하여 인증을 시도다.
이 때, 실제 인증은 PrincipalDetailsService의 loadUserByUsername 메서드에서 이루어진다.
이 메서드는 username을 기반으로 User 정보를 DB에서 가져와서 UserDetails 객체로 변환하는 역할을 한다.
5. loadUserByUsername 메서드에서 인증이 완료되면 Authentication 객체가 반환된다.
이 객체에는 로그인한 사용자의 정보가 담겨있다.
인증에 성공한 사용자의 정보는 UserDetails 인터페이스를 구현한 객체인 PrincipalDetails에 담겨 반환된다.
-> DB에 있는 username과 password가 일치한다는 뜻
3) UserDetails를 세션에 담고
그저 return authentication; 해주면 된다!
(권한 관리를 security를 대신 해주기 때문에 편하려고 하는 것!)
굳이 JWT 토큰을 사용하면서 세션을 만들 이유가 없다.
권한 처리가 필요 없다면 session에 넣어주지 않아도 된다!!!
(세션에 저장하는 부분은 이후에 나온다!)
* 인증 관련 Arcitecture
자 중간 점검을 한 번 해보자.
해당 절차로 인증에 성공하면, Authentication 객체가 생성하고 반환한다고 했다.
이 객체는 PrincipalDetails를 포함하며, 여기에는 사용자 정보와 사용자가 가진 권한 등이 포함될 수 있다.
이 객체는 세션에 저장되거나, JWT 토큰을 발급하는 등의 다양한 방식으로 사용될 수 있다.
-> 자 그렇다면 이어서 JWT 토큰을 발급하여 응답해줘보자! (마지막 단계)
AccessToken / RefeshToken 발급과
클라이언트가 요청할 때마다 해당 토큰을 들고 올 것인데, 해당 토큰이 유효한 지 검증하는 로직
2 가지는 다음 포스트에서 다루고 마무리하겠다.