먼저 Spring security가 적용되어 있어야 합니다. 그러기 위해서는 (1)편을 읽고 오는 것을 추천드립니다.
Security에서 FormLogin을 비활성화 했으므로 우리는 시큐리티 필터를 통해 인증 프로세스를 진행해야한다. 다른 블로그에서는 시큐리티의 원리와 순서까지 설명하면서 이걸 구현하고 저걸 구현하고 이런 것들을 설명하고 있는데, 사실 우리는 도구를 다루는 입장에서 블랙박스의 세세한 과정을 이해할 필요는 없다?
하지만 아주 기본적인 원리는 이해하고 있어야하는데, 원래 Generic Filter와 같은 것을 적용하면 모든 Request를 Filter를 거쳐서 도달하게 된다. 이것은 시큐리티 역시 비슷한 원리를 지닌다는 것인데, 공기청정기가 여러 개의 필터를 통해 공기를 여과하듯이 시큐리티 역시 여러 개의 필터가 있다고 생각하면 편할 것이다.
우리는 이 중에서 사용자 인증과 관련된 필터를 둘 것이고, 이를 위해서 우리가 원하는 JSON request를 받아들일 수만 있도록 하면 뒤의 과정은 기존의 시큐리티 필터의 흐름대로 처리될 것이다. 이것은 마치 내 공기청정기에 내가 만든 필터를 하나 끼워넣어서 커스텀한다고 생각하면 된다.
public class LoginFilter extends AbstractAuthenticationProcessingFilter {
private static final String DEFAULT_LOGIN_REQUEST_URL = "/login";
private static final String HTTP_METHOD = "POST";
private static final String CONTENT_TYPE = "application/json";
private static final String USERNAME_KEY = "u_id";
private static final String PASSWORD_KEY = "password";
private static final AntPathRequestMatcher DEFAULT_LOGIN_PATH_REQUEST_MATCHER =
new AntPathRequestMatcher(DEFAULT_LOGIN_REQUEST_URL, HTTP_METHOD);
private final ObjectMapper objectMapper;
public LoginFilter(ObjectMapper objectMapper) {
super(DEFAULT_LOGIN_PATH_REQUEST_MATCHER);
setSessionAuthenticationStrategy(new SessionFixationProtectionStrategy());
this.objectMapper = objectMapper;
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException {
String messageBody = StreamUtils.copyToString(request.getInputStream(), StandardCharsets.UTF_8);
Map<String, String> u_idPasswordMap = objectMapper.readValue(messageBody, Map.class);
String u_id = u_idPasswordMap.get(USERNAME_KEY);
String password = u_idPasswordMap.get(PASSWORD_KEY);
UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(u_id, password);//principal 과 credentials 전달
return this.getAuthenticationManager().authenticate(authRequest);
}
}
이렇게 필터를 설정하면 된다.
사실 코드가 길지만 결국 하고 싶은건 POST 액션으로 /login url에 대한 요청이 들어오면 해당 필터가 요청을 가로채서 뭔가를 처리한다는 건데, 나의 경우 u_id와 password를 json 형태로 보낼 것이므로 json에서 해당 키 값들을 가져온다. 그 다음 토큰에 해당 value들을 넣어서 다음 처리과정으로 전달하면 시큐리티는 해당 u_id에 해당하는 유저 정보를 읽어와서 일치하는지 보고, 일치하면 인가를 일치하지 않으면 승인 거부를 할 것이다.
이를 위해서는 당연히 시큐리티가 내가 전달해준 u_id를 가지고 데이터베이스로부터 사용자 정보를 가져오도록 도와줄 서비스가 필요한데 그걸 위해서 UserServiceDetails라는 인터페이스를 구현하면 된다.
아래에서 마저 설명한다.
public class UserDetailsServiceImpl implements UserDetailsService {
private final SessionFactory sessionFactory;
@Override
public UserDetails loadUserByUsername(String u_id) throws UsernameNotFoundException {
Session session = sessionFactory.getCurrentSession();
Query<User> query = session.createNamedQuery("User_loadUserByU_id", User.class)
.setParameter("u_id", u_id);
User user = query.stream().findAny().orElseThrow(() -> new UsernameNotFoundException("Not found User"));
return new SecurityUserDetails(user);
}
}
마찬가지로 일반적인 서비스라고 생각하면 되고, 내 경우에 프로젝트에서 DAO 단을 제거해버렸기 때문에,,, (DAO가 즉 SessionFactory라고 가정하므로) 해당 u_id에 맞는 유저를 가져오는 쿼리를 실행하여 사용자 정보를 가져온다. 이 때 해당하는 사용자가 없으면 익셉션을 발생시키도록 되어있다. 이것은 내가 (1)편에서 선언한 UserDetails와 관련이 있다.
UserDetails에서는 User객체를 가지고 있고, getName()이나 getAuthorities()와 같은 메소드를 호출하면 가지고 있는 User객체의 메소드를 호출하는 형태인데, 만약 쿼리 실행 결과로 객체가 없어서 null을 넘겨주면 UserDetails에서 null에 접근해서 메소드를 호출해버리므로 바로 오류가 나버린다. 따라서 이런 경우를 대비해서 해당하는 객체가 없으면 바로 Exception을 발생시키도록 한다.
근데 웃긴건 UsernameNotFoundException을 발생시키지만 결국 상위 클래스에서 익셉션이 발생할 경우, BadCredentialException을 다시 발생시키기 때문에 최종적으로 에러를 핸들링할 때는 BadCredentialException을 받아서 처리하는 것이 필요하다. 이 에러는 아이디와 패스워드가 일치하지 않는 경우에도 발생한다. 이것은 보안적으로 더 우수하다고 하는데 내가 생각했을 때는 이놈이 아이디 패스워드를 잘못친건지 아예 없는 아이디를 입력한건지를 구분해서 알려주면 해킹하는 입장에서는 아무 아이디나 입력하다가 이 아이디는 존재하는구나 하고 알 수 있어서가 그런게 아닐까? 어쨋든 이 내용까지는 너무 딥하니까 넘어가도록 하자
package cuk.api.Config.Security.Handler;
import com.fasterxml.jackson.databind.ObjectMapper;
import cuk.api.ResponseEntities.ResponseMessage;
import cuk.api.User.Response.LoginResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
public class LoginSuccessHandler implements AuthenticationSuccessHandler {
private final ObjectMapper objectMapper;
public LoginSuccessHandler(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
ResponseMessage resp = new ResponseMessage();
resp.setMessage("Success");
resp.setStatus(HttpStatus.OK);
LoginResponse loginResponse = new LoginResponse(authentication.getName(), authentication.getAuthorities());
resp.setData(loginResponse);
// JSON 응답 출력
response.addHeader("Content-Type", "application/json; charset=UTF-8");
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setStatus(HttpStatus.OK.value());
response.getWriter().write(objectMapper.writeValueAsString(resp));
response.getWriter().flush();
}
}
나의 경우 모든 리스폰스를 ResponseMessage로 통일시켜놓았기 때문에 해당 객체에 어떤 정보를 담아서 넘겨준다. 그리고 response.getWriter().write()로 응답을 반환해주면 된다. 아 그리고 LoginResponse 객체에는 이름과 권한정도가 담겨있다.
이것은 실패시 핸들러도 동일하다.
package cuk.api.Config.Security.Handler;
import com.fasterxml.jackson.databind.ObjectMapper;
import cuk.api.ResponseEntities.ResponseMessage;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.security.authentication.*;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
public class LoginFailureHandler implements AuthenticationFailureHandler {
private final ObjectMapper objectMapper;
public LoginFailureHandler(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException, ServletException {
ResponseMessage resp = new ResponseMessage();
resp.setStatus(HttpStatus.BAD_REQUEST);
resp.setMessage(getExceptionMessage(e));
// JSON 응답 출력
response.addHeader("Content-Type", "application/json; charset=UTF-8");
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setStatus(HttpStatus.BAD_REQUEST.value());
response.getWriter().write(objectMapper.writeValueAsString(resp));
response.getWriter().flush();
}
private String getExceptionMessage(AuthenticationException exception) {
if (exception instanceof BadCredentialsException) {
return "존재하지 않는 아이디를 입력했거나 아이디와 비밀번호가 일치하지 않습니다";
} else if (exception instanceof AccountExpiredException) {
return "만료된 계정입니다.";
} else if (exception instanceof CredentialsExpiredException) {
return "비밀번호가 만료된 상태입니다.";
} else if (exception instanceof DisabledException) {
return "비활성화된 계정입니다.";
} else if (exception instanceof LockedException) {
return "계정이 잠금처리 되어 있습니다.";
} else {
return "확인된 에러가 없습니다.";
}
}
}
이제 이 커스텀 필터를 Security에 등록해주어야한다. 위치는 정해져 있고 다음과 같이 등록해주면 된다. 그리고 커스텀 핸들러들은 이 필터 자체에 setter를 통해 등록해주면 된다.
예전에 다른 게시글에서 커스텀 필터에서 successfulAuthentication() 메소드를 오버라이드 해서 response를 작성해주었는데 Spring session과 통합해서 사용하는 사람은 이렇게 사용하면 안될 것 같다.
시큐리티에서는 SecurityContext를 세션에 담는 행위가 발생하는데 이게 내가 오버라이드한 메소드에서 담는다고 해도 세션이 똑바로 생성되지 않았다. 아 SecurityContext가 결국 HttpSession을 사용하기 때문에 SpringSession 설정만 잘 되어 있으면 자동적으로 통합이 된다. 어쨋든 Spring security와 Spring session을 사용한다면 커스텀 필터에서 successfulAuthentication() 메소드를 오버라이드 해서 성공과 실패에 대한 핸들링을 하면 세션이 정상 생성이 되지 않는다... (이유는 잘 모르겠다...)
여튼 아래에서는 Config가 어떻게 수정되는지 보여준다.
public class SecurityConfig{
private final UserDetailsService userDetailsService;
private final ObjectMapper objectMapper;
private static final String[] AUTH_WHITELIST = {
// -- Swagger UI v2
"/v2/api-docs",
"/swagger-resources",
"/swagger-resources/**",
"/configuration/ui",
"/configuration/security",
"/swagger-ui.html",
"/webjars/**",
// -- Swagger UI v3 (OpenAPI)
"/v3/api-docs/**",
"/swagger-ui/**",
// default
"/login",
"/api/**"
};
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers("/admin").hasRole(Role.ADMIN.getRoleWithoutPrefix())
.antMatchers("/auth/**").hasRole(Role.MEMBER.getRoleWithoutPrefix())
.antMatchers(AUTH_WHITELIST).permitAll()
.anyRequest().authenticated()
.and()
.csrf().disable()
.headers().disable()
.httpBasic().disable()
.formLogin().disable()
.rememberMe().disable()
.addFilterBefore(loginFilter(), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public static PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
@Bean
public DaoAuthenticationProvider daoAuthenticationProvider() throws Exception {
DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider();
daoAuthenticationProvider.setUserDetailsService(userDetailsService);
daoAuthenticationProvider.setPasswordEncoder(passwordEncoder());
return daoAuthenticationProvider;
}
@Bean
public AuthenticationManager authenticationManager() throws Exception {//AuthenticationManager 등록
DaoAuthenticationProvider provider = daoAuthenticationProvider();//DaoAuthenticationProvider 사용
provider.setPasswordEncoder(passwordEncoder());//PasswordEncoder로는 PasswordEncoderFactories.createDelegatingPasswordEncoder() 사용
return new ProviderManager(provider);
}
@Bean
public LoginFilter loginFilter() throws Exception {
LoginFilter loginFilter = new LoginFilter(objectMapper);
loginFilter.setAuthenticationManager(authenticationManager());
loginFilter.setAuthenticationSuccessHandler(loginSuccessHandler());
loginFilter.setAuthenticationFailureHandler(loginFailureHandler());
return loginFilter;
}
@Bean
public LoginSuccessHandler loginSuccessHandler() {
return new LoginSuccessHandler(objectMapper);
}
@Bean
public LoginFailureHandler loginFailureHandler() {
return new LoginFailureHandler(objectMapper);
}
}
아래의 빈들은 다 필터를 위해서 필요한 빈들이다.
그리고 추가적으로 패스워드를 암호화 하는 빈이 있는데, 시큐리티는 기본적으로 패스워드가 암호화 된다고 가정하기 때문에 해당 빈을 회원가입같은 곳에서 사용해서 DB에 패스워드가 저장될 때 암호화를 해주면 된다.
그러면 passwordEncoder를 authenticationManager에 등록을 해둔상태에서 자동적으로 암호화된 비밀번호를 비교해서 일치하는지 불일치하는지를 판단해준다.
나도 정말 많이 헤맸는데 빈을 검색하는 component-scan이나 mvc:annotation-driven과 같은 설정들이 dispatcher-servlet.xml에 있으면 안된다. 왜냐하면 시큐리티는 웹 앱 실행과 동시에 거의 가장 먼저 초기화되기 때문에 해당 빈들을 시큐리티 실행 시에 검색될 수 있도록 하려면 applicationContext.xml에 등록되어 있어야한다. 디스패처는 이 후 설정되므로 빈 관련 설정들은 옮기도록 하자.
http.sessionManagement().migrateSession() 또는 changeSessionId()를 설정해도 제대로 먹히지 않는 경우가 있다.
일단 먼저 fixation을 하는 이유는 보안과 관련이 있다. 한국어로는 세션 고정 보호라고 하는데 이 개념은 쉬우니까 알고 오는 걸 추천한다.
그래서 문제의 원인은 우리처럼 JSON 로그인 구현을 위해 커스텀 필터를 선언하는 경우이다. 이런 경우 해당 필터의 fixation 설정이 기본적으로 false이므로 아무리 시큐리티 컨피그에서 설정해도 먹히지 않는 것이다.!!! 따라서 위의 커스텀 필터의 생성자를 보면 SessionAuthenticationStrategy() 메소드 내에서 설정을 해준다면 제대로 동작되는 것을 확인할 수 있다.
이것은 본문에서도 설명했는데 커스텀 필터의 successfulAuthentication() 메소드를 오버라이드하고 성공에 대한 처리를 해서 JSON response를 반환해버리면 세션이 생성되지 않는다. 이것은 아무래도 response를 쓴 이후로부터는 다음에 수행되어야 할 어떤 동작들이 수행되지 않는 것을 의미하는데 그런 것도 문제가 되고, 해당 메소드 내부에서 SecurityContext를 담는 어떤 동작을 제대로 명시하지 않는 경우에 세션이 생성이 안되는 것 같다. 따라서 본문처럼 핸들러 빈을 만들고 그 빈을 커스텀 필터에 등록하는 식으로 한다면 내가 굳이 세션 처리를 하지 않아도 잘 생성된다. 우리는 복잡한 것이 싫어서 도구를 사용하는 입장이니까 시큐리티에게 그런 것을 맡기는 쪽이 맞는 것 같다.
이것 역시 그냥 커스텀 핸들러 빈을 만들어서 시큐리티에 등록하는 것이다. 이것은 참고 문서가 많으니까 여기서는 다루지 않겠다. 그래도 서칭을 위해 키워드를 알려주면 전자는 AuthenticationEntryPoint를 이용해서 핸들링하고 후자는 AccessDeniedHandler를 이용해서 핸들링한다.
시큐리티 관련 코드가 궁금하다면 내 프로젝트를 참고해도 좋다. Config/Security 내에 시큐리티와 관련된 모든 컴포넌트들이 모여있다.
https://github.com/THREEBACKSU/gajang-backend
와 Spring boot가 아니라 일반 Spring에서 API 형태로 시큐리티를 적용하는 것은 문서가 거의 없어서 굉장히 많은 시간을 보냈던 것 같다. 너무 많은 줄글이라 가독성도 떨어지지만 누군가는 도움이 되었으면 좋겠다....
여기서는 Hibernate JPA에 대한 설명없이 코드들을 늘어놓다보니 무작정 복붙하는 사람에게는 힘들 수 있다. 요즘 GPT 성능이 좋은데 자신이 사용하는 기술스택으로 바꿔달라고하면 잘 바꿔주지 않을까 싶다!!