Member
엔티티 관련 기본 구현SecurityConfig
작성UserDetails
오늘은 로그인을 하고 액세스 토큰과 리프레시 토큰을 받아와보자.
JsonUsernamePasswordAuthenticationFilter
JSON 데이터로 들어오는 이메일 및 패스워드 인증을 처리하고 액세스 토큰과 리프레시 토큰을 발급하는 필터를 만들어보자.
public class JsonUsernamePasswordAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
private static final String DEFAULT_FILTER_PROCESSES_URL= "/login";
private static final String HTTP_METHOD = "POST";
private static final String CONTENT_TYPE = "application/json";
private static final String USERNAME_KEY = "username";
private static final String PASSWORD_KEY = "password";
public JsonUsernamePasswordAuthenticationFilter() {
super(DEFAULT_FILTER_PROCESSES_URL);
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException {
if (!request.getMethod().equals(HTTP_METHOD)) {
throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
}
if (!request.getContentType().equals(CONTENT_TYPE)) {
throw new AuthenticationServiceException("Authentication content type not supported: " + request.getContentType());
}
String messageBody = StreamUtils.copyToString(request.getInputStream(), StandardCharsets.UTF_8);
ObjectMapper mapper = new ObjectMapper();
Map<String, String> usernamePasswordMap = mapper.readValue(messageBody, new TypeReference<Map<String, String>>() {});
String username= usernamePasswordMap.get(USERNAME_KEY);
String password = usernamePasswordMap.get(PASSWORD_KEY);
UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(username, password);
return this.getAuthenticationManager().authenticate(authRequest);
}
}
이 클래스는 AbstractAuthenticationProcessingFilter
를 extend하고 있다.
public abstract class AbstractAuthenticationProcessingFilter extends GenericFilterBean implements ApplicationEventPublisherAware, MessageSourceAware {
private AuthenticationManager authenticationManager;
private RequestMatcher requiresAuthenticationRequestMatcher;
private AuthenticationSuccessHandler successHandler = new SavedRequestAwareAuthenticationSuccessHandler();
private AuthenticationFailureHandler failureHandler = new SimpleUrlAuthenticationFailureHandler();
}
클래스 안을 보면 많은 필드가 있는데, 그 중 주목할 만한 것들은 위 정도가 있어 보인다.
authenticationManager
: 인증을 수행requiresAuthenticationRequestMatcher
: 인증이 필요한 URLsuccessHandler
: 인증 성공 시 수행할 작업의 핸들러failureHandler
: 인증 실패 시 수행할 작업의 핸들러JwtUsernamePasswordAuthenticationFilter
의 생성자에서는 로그인의 엔드포인트를 지정한다. 해당 엔드포인트로 요청이 들어오면 attemtAuthentication()
에서 우선 요청의 메서드, 컨텐트 타입을 확인하는데, Json 데이터를 POST로 보내고 있으니, POST
와 application/json
이 되어야 한다.
아래는 요청으로 들어오는 JSON을 파싱해 username
과 password
를 가져오는 부분이다. 이렇게 얻은 username
과 password
를 가지고, AbstractAuthenticationProcessingFilter
의 authenticationManager
로 인증을 시도해본다. 만약 성공하면 Authentication
객체가 반환한 후 해당 객체로 로그인 성공 핸들러가 실행될 것이고, 그렇지 않으면 AuthenticationException
이 발생할 것이다.
LoginSuccessHanlder
작성@RequiredArgsConstructor
public class LoginSuccessHandler implements AuthenticationSuccessHandler {
private final JwtService jwtService;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
String username = authentication.getName();
String accessToken = jwtService.generateAccessToken(username);
String refreshToken = jwtService.generateRefreshToken();
response.setContentType("application/json");
response.setCharacterEncoding("utf-8");
jwtService.setAccessToken(response, accessToken);
jwtService.setRefreshToken(response, refreshToken);
}
}
로그인이 성공하면 위의 attemptAuthentication()
에서 반환되는 Authentication
객체를 가지고 이 핸들러가 실행된다. 여기서 하는 일은 간단히 해당 객체에서 사용자 이름을 추출하고, 액세스 토큰과 리프레시 토큰을 만들어 응답에 담아주는 것이다.
SecurityConfig
수정이제 만든 JsonUsernamePasswordAuthenticationFilter
와 LoginSuccessHandler
를 등록해줘야 한다. SecurityConfig
를 수정하자.
@Bean
public PasswordEncoder passwordEncoder() {
DelegatingPasswordEncoder delegatingPasswordEncoder = (DelegatingPasswordEncoder) PasswordEncoderFactories.createDelegatingPasswordEncoder();
delegatingPasswordEncoder.setDefaultPasswordEncoderForMatches(new BCryptPasswordEncoder());
return delegatingPasswordEncoder;
}
@Bean
public UserDetailsService userDetailsService() {
return new CustomUserDetailsService(memberRepository);
}
@Bean
public AuthenticationManager authenticationManager() throws Exception {
DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider();
daoAuthenticationProvider.setPasswordEncoder(passwordEncoder());
daoAuthenticationProvider.setUserDetailsService(userDetailsService());
return new ProviderManager(daoAuthenticationProvider);
}
@Bean
public AuthenticationSuccessHandler loginSuccessHandler(){
return new LoginSuccessHandler(jwtService);
}
@Bean
public JsonUsernamePasswordAuthenticationFilter jsonUsernamePasswordAuthenticationFilter() throws Exception {
JsonUsernamePasswordAuthenticationFilter filter = new JsonUsernamePasswordAuthenticationFilter();
filter.setAuthenticationManager(authenticationManager());
filter.setAuthenticationSuccessHandler(loginSuccessHandler());
return filter;
}
맨 아래의 jsonUsernamePasswordAuthenticationFilter()
에서는 인증에서 사용할 authenticationManager
와 loginSuccessHandler
를 지정해줬다.
authenticationManager()
에서는 AuthenticationManager
인터페이스의 구현체를 지정해주고 있다. DaoAuthenticationProvider
는 사용자 이름과 패스워드로 Userdetails
를 가져오는 클래스다. 인증 요청으로 들어오는 패스워드를 인코딩할 때 쓸 passwordEncoder
와, Userdetails
를 가져올 때 사용할 userDetailsService
도 설정해준다. PasswordEncoder
와 관련한 내용은 여기에 있다.
이전에는 패스워드를 그냥 그대로 저장했다. 평문을 그대로 사용하는
NoOpPasswordEncoder
를 사용할 수도 있겠지만, 이제는 패스워드도 암호화해서 저장하도록 한다.
public class MemberServiceImpl implements MemberService{
// ...
private final PasswordEncoder passwordEncoder;
@Override
@Transactional
public Member joinMember(MemberJoinDto memberJoinDto) throws Exception {
if (isDuplicatedUsername(memberJoinDto.getUsername())) throw new Exception("중복된 이메일입니다.");
Member member = Member.builder()
.username(memberJoinDto.getUsername())
.password(passwordEncoder.encode(memberJoinDto.getPassword()))
.role(Role.GUEST)
.build();
return memberRepository.save(member);
}
//...
}
MemberServiceImpl
에 PasswordEncoder
빈을 주입시키고, 회원가입 시 해당 인코더로 평문패스워드를 암호화해 저장하도록 변경했다.
이렇게 만든 필터는 필터 체인에 추가해줘야 한다.
@Bean
public SecurityFilterChain httpFilterChain(HttpSecurity http) throws Exception {
http
.httpBasic(AbstractHttpConfigurer::disable)
.cors(cors ->
cors.configurationSource(corsConfigurationSource()))
.csrf(AbstractHttpConfigurer::disable)
.formLogin(AbstractHttpConfigurer::disable)
.sessionManagement(sessionManagement -> sessionManagement
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests((authorizeRequests) -> authorizeRequests
.requestMatchers("/login").permitAll()
.requestMatchers("/member/register").permitAll()
.anyRequest().authenticated()
);
http.addFilterAfter(jsonUsernamePasswordAuthenticationFilter(), LogoutFilter.class);
return http.build();
}
저번 글과 달리 .authorizeHttpRequests(...)
이 추가됐다. /login
과 /member/register
를 제외한 모든 요청들에 대해 인증을 요구하도록 했다.
맨 아래의 http.addFilterAfter(jsonUsernamePasswordAuthenticationFilter(), LogoutFilter.class);
에서는 필터를 필터 체인에 추가해주고 있다. 필터를 LogoutFilter
의 뒤에 추가해주고 있는데, 이와 관련해서는 이 글에 잘 정리되어 있다.
@Bean
public CorsConfigurationSource corsConfigurationSource(){
CorsConfiguration corsConfig = new CorsConfiguration();
corsConfig.addAllowedOrigin("http://localhost:3000");
corsConfig.setAllowCredentials(true);
corsConfig.addAllowedHeader("*");
corsConfig.addAllowedMethod("*");
corsConfig.addExposedHeader("Authorization");
corsConfig.addExposedHeader("Set-Cookie");
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", corsConfig);
return source;
}
CORS 관련 설정에서도 "Authorization", "Set-Cookie" 헤더를 노출시키도록 설정해줘야 한다.
따로 페이지는 만들지 않고, 버튼만 하나 추가해줬다.
// ./compoent/SignupForm.js
//...
const handleLogin = async (e) => {
e.preventDefault();
const body = {
username: username,
password: password
};
axios.post('http://localhost:8080/login', body,
{ withCredentials: true },
)
.then((res) => {
if (res.status === 200) {
let accessToken = res.headers['authorization'];
console.log(accessToken);
}
})
.catch((res) => {
console.log(res);
})
}
return (
<div className="formContainer">
<form onSubmit={handleSubmit} className="form">
<div className="formGroup">
<input className="formInput" onChange={e=>setUsername(e.target.value)} type="id" placeholder="이메일 입력" />
</div>
<div className="formGroup">
<input className="formInput" onChange={e=>setPassword(e.target.value)} type="password" placeholder="비밀번호 입력" />
</div>
<button onClick={handleSubmit} className='submitButton' type='submit'> sign up </button>
<button onClick={handleLogin} className='submitButton' type='submit'> login </button>
</form>
</div>
)
요청을 보낼 때 withCredentials: true
를 추가해주는 것을 잊지 말자. 해당 옵션을 빼먹으면 쿠키 설정이 안 된다.
대충 아무거나 쳐서 회원가입한 후 로그인을 누르면, 콘솔에 액세스 토큰이 잘 찍혀나온다.
리프레시 토큰도 쿠키에 잘 들어가있다.
여담으로 블로그들을 보다가 스프링 시큐리티와 관련한 좋은 글을 찾았다. 반성을 하게 되는 글이다.