티스토리 뷰

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 으로 설정해두면, 같은 서버에서는 이를 유발할 수 있는 태그들의 렌더링을 허용한다는 의미이다.
  • 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);
    }
}

해당 코드는 구현이 완료된 이후에 삭제되었다.

  • 변경 이유 
    1. 응답을 필터에서 내려주는 것이 별로이다.
      • 직접 서블릿의 리스폰스를 세팅하고, 내용을 담아줘야 한다. 처음에는 이와 같이 할 수밖에 없었던 이유는 내장된 로그인 기능을 쓰고 있었기 때문에 이를 건드릴 수가 없어서 응답을 필터에서 내려줄 수밖에 없었다.
      • 또한, 추후에는 로그인 시에 리프레시 토큰을 같이 반환해주면서 이를 DB 에 저장해야 했는데 트랜잭션이 필터에서 열린다는 것은 도저히 납득이 가지 않았기에 아예 삭제를 했다.
    2. 필요가 없다.
      • 간단하다. 위와 같은 이유로 로그인을 직접 구현하게 됐고 동일한 기능을 하게 되어 굳이 해당 필터가 필요가 없어졌다

그러나, 지금 게시글에서는 액세스 토큰을 발급하는 기능을 하는 것이 목적이기에 넣어두도록 하겠다!

 

  • 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

 

댓글
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
TAG
more
«   2025/05   »
1 2 3
4 5 6 7 8 9 10
11 12 13 14 15 16 17
18 19 20 21 22 23 24
25 26 27 28 29 30 31
글 보관함