티스토리 뷰
https://heethehope.tistory.com/186
[스프링부트] 스프링 시큐리티의 구조와 처리 과정 / 로그인은 어떻게 이루어질까?
프로젝트에서 로그인을 맡게 되었다. 이를 구현하는 과정 중에 시큐리티를 우선 이해하고 구현하는 것이 좋을 것 같아 코드를 파보게 되었다. 🧙🏻 시큐리티의 동작 순서 우선 자세한 설명에
heethehope.tistory.com
이전 글에 작성한 것을 토대로 시큐리티를 이해하고 로그인을 구현하기 시작했다.
우선, 내가 참여한 프로젝트에서는 로그인에 성공할시, jwt 액세스 토큰을 발급해주기로 했다.
이 글은 프로젝트 구현이 끝난 뒤에 작성되었기 때문에 현재의 코드와 일부 상이하며 그에 따른 오류가 존재할 가능성이 있다.
따라서 따라 치면서 참고할 만한 코드는 아닌 것 같다.
그냥 시큐리티 + 필터 + 토큰을 이용한 로그인에 대한 개념을 이해하는 정도로 생각해주시면 감사하겠습니당 🙇🏼♀️
오늘은 액세스 토큰을 발급하는 부분까지만 다뤄보고, 추후에 리프레시 토큰에 대한 발급과 리프레시 토큰을 이용하여 액세스 토큰을 재발급하는 것까지 다뤄보도록 하겠다.
🐰 Build.gradle
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.security:spring-security-test'
implementation 'io.jsonwebtoken:jjwt:0.9.1
우선 필요한 의존성을 추가해준다.
🐰 SecurityConfig.java
@RequiredArgsConstructor
@EnableWebSecurity
public class SecurityConfig {
private final AuthenticationConfiguration authenticationConfiguration;
private final JwtTokenProvider jwtTokenProvider;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
AuthenticationManager authentication = authenticationManager(authenticationConfiguration);
JwtAuthenticationFilter authenticationFilter = new JwtAuthenticationFilter(authentication,
jwtTokenProvider);
http.cors().configurationSource(corsConfigurationSource()).and().csrf().disable()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
.formLogin().disable().httpBasic().disable()
.addFilter(authenticationFilter)
.addFilter(new JwtAuthorizationFilter(authentication, jwtTokenProvider))
.authorizeRequests();
http.authorizeRequests()
.antMatchers("/user/login/**", "/user/signup", "/articles/**", "/comments",
"/user/password", "/health-check").permitAll()
.and().headers()
.addHeaderWriter(new XFrameOptionsHeaderWriter(
XFrameOptionsHeaderWriter.XFrameOptionsMode.SAMEORIGIN))
.and().logout().logoutRequestMatcher(new AntPathRequestMatcher("/user/logout"))
.logoutSuccessUrl("/user/login").invalidateHttpSession(true);
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public AuthenticationManager authenticationManager(
AuthenticationConfiguration authenticationConfiguration
) throws Exception {
return authenticationConfiguration.getAuthenticationManager();
}
}
위와 같이 config 파일을 작성해주었다.
- AuthenticationManager 은 이전 글에 작성한 것과 같이 인증 로직을 담당하는 provider 를 관리하는 객체이다.
- JwtAuthenticationFilter 를 사용할 것이며, 해당 필터가 동작하는 URI 즉, 로그인 요청 URI 를 setFilterProcessURL 을 통해서 설정해준다.
- 세션을 사용하지 않고, 토큰을 이용하여 로그인하기로 했기에 SessionCreationPolicy.STATELESS 로 해두었다.
- http.csrf().disable() 은 csrf 설정을 disable 한다는 의미이다.
- 왜 해당 요청을 disable 로 설정할까session 을 사용하지 않게 되면, stateless 하기 때문에 서버측에 인증 정보를 저장하지 않기에 사용자가 의도하지 않은 위조요청을 걸러낼 필요가 없다. 따라서 이와 관련된 세팅들을 끄는 것이다.
- formLogin 은 시큐리티에서 제공하는 로그인을 사용할 것인지의 여부이다. 나는 이를 사용하지 않을 것이기 때문에 이를 disable 해주었다.
- httpBasic은 http basic auth 를 기반으로 인증할 것인지 설정하는 것이다. 이 역시도 꺼준다.
- 이전 게시글에서 언급한 것과 같이, 시큐리티는 필터를 기반으로 동작한다. 인증 필터를 커스텀하고 addFilter 를 이용하면 이를 끼워넣을 수 있다.
- 따라서 jwtAuthentication, jwtAuthorizationFilter 를 끼워넣었다
- permitAll() 은 해당 리소스의 접근은 인증절차 없이 사용한다는 것이다.
- 내가 진행했던 프로젝트에서는 로그인된 사용자가 아니더라도 커뮤니티 글의 조회까지 가능했기 때문에 위와 같이 설정해뒀다.
- XFrameOptionsHeaderWriter 를 이용하면 clickjacking 공격을 방지할 수 있다.
- clickjacking 공격이란
사용자가 자신이 클릭하고 있다고 인지하는 것과 다른 것을 클릭하도록 속이는 기법잠재적으로 제어를 획득할 수 있음 - 이를 SAMEORIGIN 으로 설정해두면, 같은 서버에서는 이를 유발할 수 있는 태그들의 렌더링을 허용한다는 의미이다.
- clickjacking 공격이란
- invalidateHttpSession 은 브라우저를 종료하지 않을 때, 로그아웃 시에 로그인했던 정보를 모두 삭제하는 것이다.
🐰 LoginVo.java
@Getter
@NoArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
public class LoginVo {
String email;
String password;
@Builder
public LoginVo(
String email,
String password
) {
this.email = email;
this.password = password;
}
}
로그인 요청 시에 body 로 넘어올 vo를 정의했다.
vo 는 생성자를 통해서 생성된 이후에는 내부의 값을 변경하지 않는다. 따라서 setter 가 존재하지 않는다. 로그인은 사용자가 입력한 값 그대로에 대해서 검증이 이뤄져야 하기 때문에 vo 로 정의했다.
🐰 JwtProvider.java / PrincipalDetails.java
@Component
@RequiredArgsConstructor
@Slf4j
public class JwtTokenProvider {
@Value("${jwt.secret-key}")
private String secretKey;
@Value("${jwt.valid-time}")
private long TOKEN_VALID_TIME;
public static final String ACCESS_TOKEN_HEADER_STRING = "Authorization";
public static final String REFRESH_TOKEN_HEADER_STRING = "RefreshToken";
public static final String EXPIRE_DATE_STRING = "expireDate";
public static final String TOKEN_PREFIX = "Bearer ";
private final UserRepository userRepository;
public String createAccessToken(User user) {
return JWT.create().withSubject(user.getEmail())
.withExpiresAt(new Date(System.currentTimeMillis() + TOKEN_VALID_TIME)).withClaim("id", user.getId())
.withClaim("email", user.getEmail()).sign(Algorithm.HMAC512(secretKey));
}
public Authentication getAuthentication(String jwtToken) {
String token = jwtToken.replace(TOKEN_PREFIX, "");
String email = JWT.require(Algorithm.HMAC512(secretKey)).build().verify(token).getClaim("email").asString();
if (email != null) {
Optional<User> userEntity = userRepository.findByEmail(email);
try {
User user = userEntity.get();
PrincipalDetails principalDetails = new PrincipalDetails(user);
return new UsernamePasswordAuthenticationToken(principalDetails, null,
principalDetails.getAuthorities());
} catch (Exception e) {
throw new UserNotFoundException();
}
}
return null;
}
public String getSecretKey() {
return secretKey;
}
}
public class PrincipalDetails implements UserDetails {
private User user;
public PrincipalDetails(User user) {
this.user = user;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
Collection<GrantedAuthority> authorities = new ArrayList<GrantedAuthority>();
authorities.add(new SimpleGrantedAuthority("user"));
return authorities;
}
@Override
public String getPassword() {
return user.getPassword();
}
@Override
public String getUsername() {
return user.getEmail();
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
public User getUser() {
return user;
}
}
위의 내용을 설명하기 전에, 이전 게시글에 작성해뒀던 로직을 다시 보자.
Authentication 객체는 실제로 DB 단에 접근하는 UserDetailsService 에서 UserDetails 인터페이스와의 비교를 통해 인증된 객체가 될 수 있다. PrincipalDetails 는 UserDetails 를 구현한 것이다.
JwtProvider 는 토큰을 생성하고 토큰에 담겨 있는 정보에서 유저 정보를 꺼내 Authentication 객체를 반환하는 getAuthentication 메서드가 정의되어 있다. 또한, 사용되는 Authentication 객체는 UsernamePasswordAuthenticationToken 이다.
이를 유념하며 아래 설명글을 봐주기를 바란다.
- createAccessToken
- subject는 토큰의 제목을 의미한다. 주로 유저의 고유한 값을 사용한다. 나의 프로젝트에서는 이메일로 로그인을 했기에 이메일로 설정했다.
- withExpiresAt 은 만료 기한을 의미한다. 여기에서는 현재 날짜를 밀리초로 바꾸고, 프로퍼티스에 정의한 유효기간을 더해줬다.
- withClaim 은 토큰에 담긴 정보들을 의미한다. 즉, 다시 말해 위와 같이 되어 있다면, 토큰에는 id 라는 key 로 유저의 아이디와 email 이라는 key 로 유저의 이메일이 담기는 것이다.
- sign 은 유효성 검증 시에 사용한다. 헤더와 페이로드의 값을 인코딩하고, 이를 다시 비밀키를 이용하여 정의한 알고리즘으로 해싱을 한다. 또한 이를 다시 인코딩하여 토큰이 생성되는 것이다.
- 토큰의 헤더에는 알고리즘, 타입이 정의된다.
- 나의 경우에는 HMAC512, JWT 가 되는 것이다.
- getAuthentication
- 토큰에는 prefix 가 붙기 때문에 prefix 인 Bearer 를 토큰에서 제거해준 값을 token 이라는 변수에 담는다.
- 이전에 withClaim 으로 토큰에 담아줬던 유저의 이메일을 getClaim 을 통해서 꺼내준다.
- 여기서, JWT.require은 토큰의 유효성을 미리 지정해둔 알고리즘, 시크릿키를 통해서 검증하는 객체를 만들어주고, 해당 객체에서 verify 를 하면 토큰의 유효성이 검증된다.
- email 이 null 이라면 정상적인 토큰이 아니므로 (정상적인 토큰은 당연히 이메일이 담겨있다.) null을 반환한다.
- 여담이지만, 이는 클린코드 상으로 null 을 반환하는 것은 당연히 좋은 코드가 아니라고 한다. 추후에 리팩토링을 해야겠다
- 유저를 찾고, 없다면 에러를 던지고 유저가 존재한다면 해당 객체를 꺼내 UserDetails를 구현한 PrincipalDetails 를 생성한다. 이를 통해서 이전에 설명했던 것과 같이, UsernamePasswordAuthenticationToken 을 생성하여 반환한다.
🐰 JwtAuthenticationFilter.java
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
private final AuthenticationManager authenticationManager;
private final ObjectMapper mapper = new ObjectMapper();
private final JwtTokenProvider jwtTokenProvider;
private final UserRepository userRepository;
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
throws AuthenticationException {
try {
ObjectMapper om = new ObjectMapper();
LoginVo user = om.readValue(request.getInputStream(), LoginVo.class);
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(
user.getEmail(), user.getPassword());
return authenticationManager.authenticate(authenticationToken);
} catch (IOException e) {
e.printStackTrace();
}
return null;
}
// 로그인에 성공할 시에 해당 토큰을 반환
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
Authentication authResult) throws IOException {
PrincipalDetails principalDetails = (PrincipalDetails) authResult.getPrincipal();
User userEntity = principalDetails.getUser();
String jwtToken = jwtTokenProvider.createToken(userEntity.getEmail(), userEntity.getRoles());
Map<String, String> json = new HashMap<>();
json.put("msg", "정상적으로 토큰이 발급되었습니다");
json.put("token", jwtToken);
String jsonResponse = mapper.writeValueAsString(ResponseEntity.status(200).body(json));
response.setContentType("application/json");
response.setCharacterEncoding("utf-8");
response.getWriter().write(jsonResponse);
}
}
해당 코드는 구현이 완료된 이후에 삭제되었다.
- 변경 이유
- 응답을 필터에서 내려주는 것이 별로이다.
- 직접 서블릿의 리스폰스를 세팅하고, 내용을 담아줘야 한다. 처음에는 이와 같이 할 수밖에 없었던 이유는 내장된 로그인 기능을 쓰고 있었기 때문에 이를 건드릴 수가 없어서 응답을 필터에서 내려줄 수밖에 없었다.
- 또한, 추후에는 로그인 시에 리프레시 토큰을 같이 반환해주면서 이를 DB 에 저장해야 했는데 트랜잭션이 필터에서 열린다는 것은 도저히 납득이 가지 않았기에 아예 삭제를 했다.
- 필요가 없다.
- 간단하다. 위와 같은 이유로 로그인을 직접 구현하게 됐고 동일한 기능을 하게 되어 굳이 해당 필터가 필요가 없어졌다
- 응답을 필터에서 내려주는 것이 별로이다.
그러나, 지금 게시글에서는 액세스 토큰을 발급하는 기능을 하는 것이 목적이기에 넣어두도록 하겠다!
- attemptAuthentication
- 이전 게시글에 언급한 것과 같이 해당 메서드는 AuthenticationFilter 로 요청이 들어오면 실행되는 메서드이다.
- 이전에 언급한 LoginVo 에서 값을 읽고 providers 를 갖고 있는 AuthenticationManager 를 호출하여 실제 인증 로직으로 넘어갈 수 있도록 한다.
- 여기서 만들어진 UsernamePasswordAuthenticationToken 은 인증이 되지 않은 객체이다.
- successfulAuthentication
- 해당 메서드는 위의 그림에서 보이는 모든 로그인 로직이 수행이 된 이후에 인증이 된 Authentication 객체를 반환한다.
- 위 필터에서는 토큰을 반환하도록 한 것이다.
🐰 JwtAuthorizationFilter.java
public class JwtAuthorizationFilter extends BasicAuthenticationFilter {
private final JwtTokenProvider jwtTokenProvider;
public JwtAuthorizationFilter(AuthenticationManager authenticationManager, JwtTokenProvider jwtTokenProvider) {
super(authenticationManager);
this.jwtTokenProvider = jwtTokenProvider;
}
public void doFilterInternal(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
String token = jwtTokenProvider.getToken((HttpServletRequest) request);
if (token != null && jwtTokenProvider.validateToken(token)) {
Authentication authentication = jwtTokenProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
chain.doFilter(request, response);
}
}
해당 필터는 인가를 담당한다. 요청이 들어왔을 때, 권한이 필요한 요청이라면 헤더에서 액세스 토큰을 가져와서 유효성을 검사하고 유효하다면 토큰에서 유저 정보를 꺼내 Authentication 객체를 SecurityContextHolder 에 등록한다.
이렇게 하면 로그인에 대한 구현은 끝이다!
🐰 참고 링크
https://velog.io/@woohobi/Spring-security-csrf%EB%9E%80
Spring security - csrf란?
모든 코드는 github에 있습니다.최근에 Spring security를 한창 공부하고 있는데 (조만간 블로그에 security 관련 글이 여러개 올라갈것 같다) 한 가지 궁금하게 한 코드가 있었다.http.csrf().disable()에서 csr
velog.io
https://powernote.tistory.com/2
Spring boot Security ( api server 기반 )
* 스프링 에서의 Security 는 상당히 중요한 부분이다. * 나름대로 이부분에 대한 정리 사항들을 하려 한다. 1. Spring boot project 생성. 일반적인 프로젝트를 생성한다. 2.SecurityConfig class 생성. Security 를
powernote.tistory.com
https://kangwoojin.github.io/programing/spring-security-basic-header-wrtier-filter/
[Spring Security] Spring Security Basic - HeaderWriterFilter
HeaderWriterFilter가 언제 생성되고 어떤 역할을 하고 있는지 알아 보자.
kangwoojin.github.io
https://mangkyu.tistory.com/56
[Server] JWT(Json Web Token)란?
현대 웹서비스에서는 토큰을 사용하여 사용자들의 인증 작업을 처리하는 것이 가장 좋은 방법이다. 이번에는 토큰 기반의 인증 시스템에서 주로 사용하는 JWT(Json Web Token)에 대해 알아보도록 하
mangkyu.tistory.com
'스프링부트' 카테고리의 다른 글
[스프링부트] 쿠키를 이용하여 클라이언트와 통신 시 마주했던 에러들 (0) | 2023.03.14 |
---|---|
[스프링부트] 스프링 시큐리티의 구조와 처리 과정 / 로그인은 어떻게 이루어질까? (0) | 2023.02.12 |
[스프링부트] ResponseEntity 싱글톤 패턴 따르기 (0) | 2022.10.23 |
[스프링부트] 비동기처리 개선하기 (0) | 2022.10.12 |
[스프링부트] batch 사용해보기2 (0) | 2022.10.10 |