Spirng Security + Jwt 로그인 적용하기

YoungHo-Cha·2022년 2월 25일
47

운동 매칭 시스템

목록 보기
4/17

프로젝트를 진행하면서 Spring Security + Jwt를 이용한 로그인을 구현하게 되었다.

📕목차

  • Spring Security
  • JWT
  • Spring SEcurity + JWT

📌Spring Security

가장먼저 스프링 시큐리티에 대해서 알아보자.

Spring Security는 Spring과는 별개로 작동하는 보안담당 프레임워크이다.

크게 두 가지의 동작을 수행한다.
1. Authenticatio(인증) : 특정 대상이 "누구"인지 확인하는 절차이다.
2. Authorization(권한) : 인증된 주체가 특정한 곳에 접근 권한을 확인하는 것이다.

Spring Security 인증과정

인증하는 과정은 위의 그림과 같다. 하나씩 살펴보자.

  1. Http Request가 서버로 넘어온다.
  2. 가장먼저 AuthenticationFilter가 요청을 낚아챈다.
  3. AuthenticationFilter에서 Request의 Username, password를 이용하여 UsernamePasswordAuthenticationToken을 생성한다.
  4. 토큰을 AuthenticationManager가 받는다.
  5. AuthenticationManager는 토큰을 AuthenticationProvider에게 토큰을 넘겨준다.
  6. AuthenticationProvider는 UserDetailsService로 토큰의 사용자 아이디(username)을 전달하여 DB에 존재하는지 확인한다. 이 때, UserDetailsService는 DB의 회원정보를 UserDetails 라는 객체로 반환한다.
  7. AuthenticationProvider는 반환받은 UserDetails 객체와 실제 사용자의 입력정보를 비교한다.
  8. 비교가 완료되면 사용자 정보를 가진 Authentication 객체를 SecurityContextHolder에 담은 이후 AuthenticationSuccessHandle를 실행한다.(실패시 AuthenticationFailureHandler를 실행한다.)

Spring Security Filter

스프링 시큐리티는 필터를 기반으로 수행된다.

필터와 인터셉터의 차이는 실행되는 시점의 차이이다.

  • 필터는 dispatcher servlet으로 요청이 도착하기 전에 동작한다.
  • 인터셉터는 dispatcher servlet을 지나고 controller에 도착하기 전에 동작한다.
  • SecurityContextPersistenceFilter : SecurityContextRepository에서 SecurityContext를 가져오거나 저장하는 역할을 한다.

  • LogoutFilter : 설정된 로그아웃 URL로 오는 요청을 감시하며, 해당 유저를 로그아웃 처리

  • (UsernamePassword)AuthenticationFilter : (아이디와 비밀번호를 사용하는 form 기반 인증) 설정된 로그인 URL로 오는 요청을 감시하며, 유저 인증 처리

    • AuthenticationManager를 통한 인증 실행
    • 인증 성공 시, 얻은 Authentication 객체를 SecurityContext에 저장 후 AuthenticationSuccessHandler 실행
    • 인증 실패 시, AuthenticationFailureHandler 실행
  • DefaultLoginPageGeneratingFilter : 인증을 위한 로그인폼 URL을 감시한다.

  • BasicAuthenticationFilter : HTTP 기본 인증 헤더를 감시하여 처리한다.

  • RequestCacheAwareFilter : 로그인 성공 후, 원래 요청 정보를 재구성하기 위해 사용된다.

  • SecurityContextHolderAwareRequestFilter : HttpServletRequestWrapper를 상속한 SecurityContextHolderAwareRequestWapper 클래스로 HttpServletRequest 정보를 감싼다. SecurityContextHolderAwareRequestWrapper 클래스는 필터 체인상의 다음 필터들에게 부가정보를 제공한다.

  • AnonymousAuthenticationFilter : 이 필터가 호출되는 시점까지 사용자 정보가 인증되지 않았다면 인증토큰에 사용자가 익명 사용자로 나타난다.

  • SessionManagementFilter : 이 필터는 인증된 사용자와 관련된 모든 세션을 추적한다.

  • ExceptionTranslationFilter : 이 필터는 보호된 요청을 처리하는 중에 발생할 수 있는 예외를 위임하거나 전달하는 역할을 한다.

  • FilterSecurityInterceptor : 이 필터는 AccessDecisionManager 로 권한부여 처리를 위임함으로써 접근 제어 결정을 쉽게해준다.

모든 필터를 달달 외울 필요는 없을 것 같고, 대충 필터들이 존재한다는 감만 익히자! 나중에 찾아보면 되니까.

필터만 나열해놓으니까 와닿지 않는다.

필자가 이해한대로 다시 써보자면, 내가 로그아웃 과정을 커스텀 하고 싶을 때 LogoutFilter를 만들어서 커스텀하면 되는 느낌이다.

그리고 새로운 Filter를 생성하고자 할 때는, securityConfig에 Filter 체인을 추가 등록해주면 된다.

필터 체인

필터들은 위의 그림과 같이 체인되어 있다. 임의의 필터를 생성하고 원하는 필터 앞이나 뒤에 삽입하면 될 듯 하다.


📌JWT (Json Web Token)

쿠키와 세션은 수 없이 많이 들어본 단어이다. 하지만 JWT는 뭔가 생소하다.(나는 그랬다.)

JWT를 접목시키기 위해서는 JWT에 대해서 가장 먼저 알아보아야 할 듯 하다.

Cookie, Session, JWT는 모두 비연결성인 네트워크 서버 특징을 연결성으로 사용하기 위한 방법이다.

자세한 내용은 생략하자!

  • Cookie & Session은 서버의 어떠한 저장소에 해당 값과 매칭되는 value를 가지고 있어야 한다. 그래서 서버 자원이 많이 사용되는 단점이 있다.

  • JWT는 Cookie & Session의 자원 문제를 해결하기 위한 방법이다. JWT는 토큰 자체에 유저 정보를 담아서 암호화한 토큰이라고 생각하면 된다. 암호화된 내용은 디코딩 과정을 통해서 해석이 가능하다.

JWT 구조

JWT는 3개의 구역이 있다.


header. payload. verify signature

  • header : Header, Payload, Verify Signature 를 암호화할 방식(alg), 타입(Type) 등을 포함한다.

  • Payload :서버에서 보낼 데이터 - 일반적으로 user의 id, 유효기간 포함한다.

  • Verify Signature : Base64 방식으로 인코딩한 Header, Payload, Secret key 를 더한 값이다.

JWT를 통한 인증절차

  1. 사용자가 로그인을 한다.

  2. 서버에서는 계정 정보를 읽어 사용자를 확인 후, 사용자의 고유 ID 값을 부여한 후 기타 정보와 함께 Payload 에 집어넣는다.

  3. JWT 토큰의 유효기간을 설정한다.

  4. 암호화할 Secret key 를 이용해 Access Token 을 발급한다.

  5. 사용자는 Access Token 을 받아 저장 후, 인증이 필요한 요청마다 토큰을 헤더에 실어 보낸다.

  6. 서버에서는 해당 토큰의 Verify Signature 를 Secret key 로 복호화한 후, 조작 여부, 유효기간을 확인한다.

  7. 검증이 완료되었을 경우, Payload 를 디코딩 하여 사용자의 ID 에 맞는 데이터를 가져온다.

JWT는 보통 Access Token의 유효기간은 매우 짧다. 이유는 보안 문제 때문이다. 그래서 Refresh Token을 따로 발급해주는데, Access Token이 만료되면 새로운 JWT를 발급할 수 있는 토큰이다.


📌Spring Security + JWT

문제는 "Spring Security와 JWT를 어떻게 같이 사용하는가?" 이다.

차근차근 천천히 해보자.

Gradle

  1. 먼저 Spring Security를 gradle로 추가하자.
implementation 'org.springframework.boot:spring-boot-starter-security'

implementation 'org.springframework.security:spring-security-test'
  1. jwt 관련 라이브러리를 추가하자.
implementation 'io.jsonwebtoken:jjwt:0.9.1'

Config

  1. 가장 먼저 Security와 Filter관련 설정을 해주어야 한다.
package com.togethersports.tosproejct.config;

@RequiredArgsConstructor
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    private final JwtTokenProvider jwtTokenProvider;

    // authenticationManager를 Bean 등록합니다.
    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {

        http.csrf().disable();
        //http.httpBasic().disable(); // 일반적인 루트가 아닌 다른 방식으로 요청시 거절, header에 id, pw가 아닌 token(jwt)을 달고 간다. 그래서 basic이 아닌 bearer를 사용한다.
        http.httpBasic().disable()
                .authorizeRequests()// 요청에 대한 사용권한 체크
                .antMatchers("/test").authenticated()
                .antMatchers("/admin/**").hasRole("ADMIN")
                .antMatchers("/user/**").hasRole("USER")
                .antMatchers("/**").permitAll()
                .and()
                .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider),
                        UsernamePasswordAuthenticationFilter.class); // JwtAuthenticationFilter를 UsernamePasswordAuthenticationFilter 전에 넣는다
        // + 토큰에 저장된 유저정보를 활용하여야 하기 때문에 CustomUserDetailService 클래스를 생성합니다.
        http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);


    }
}
  • 먼저 AuthenticationManager를 Bean으로 등록해준다.

  • 그리고 config를 하나씩 설정해주자.

    • antMatchers() : 해당 URL로 요청 시 설정을 해준다.
    • authenticated() : andMatchers에 속해있는 URL로 요청이 오면 인증이 필요하다고 설정한다.
    • hasRole() : andMatchers에 속해있는 URL로 요청이 들어오면 권한을 확인한다.
  • addFilterBefore() : 필터를 등록한다. 스프링 시큐리티 필터링에 등록해주어야 하기 때문에, 여기에 등록해주어야 한다. 파라미터는 2가지가 들어간다. 왼쪽은 커스텀한 필터링이 들어간다. 오른쪽에 등록한 필터전에 커스텀필터링이 수행된다.

  • http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) : 세션을 사용하지 않는다고 설정한다.

Custom Filter

이제 커스텀한 필터를 구현하자.

//해당 클래스는 JwtTokenProvider가 검증을 끝낸 Jwt로부터 유저 정보를 조회해와서 UserPasswordAuthenticationFilter 로 전달합니다.
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends GenericFilterBean {

    private final JwtTokenProvider jwtTokenProvider;

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        // 헤더에서 JWT 를 받아옵니다.
        String token = jwtTokenProvider.resolveToken((HttpServletRequest) request);
        // 유효한 토큰인지 확인합니다.
        if (token != null && jwtTokenProvider.validateToken(token)) {
            // 토큰이 유효하면 토큰으로부터 유저 정보를 받아옵니다.
            Authentication authentication = jwtTokenProvider.getAuthentication(token);
            // SecurityContext 에 Authentication 객체를 저장합니다.
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }
        chain.doFilter(request, response);
    }

여기서 JwtTokenProvider 또한 커스텀한 Provider이다.

Custom Provider

package com.togethersports.tosproejct.jwt;

// 토큰을 생성하고 검증하는 클래스입니다.
// 해당 컴포넌트는 필터클래스에서 사전 검증을 거칩니다.
@RequiredArgsConstructor
@Component
public class JwtTokenProvider {
    private String secretKey = "myprojectsecret";

    // 토큰 유효시간 30분
    private long tokenValidTime = 30 * 60 * 1000L;

    private final UserDetailsService userDetailsService;

    // 객체 초기화, secretKey를 Base64로 인코딩한다.
    @PostConstruct
    protected void init() {
        secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes());
    }

    // JWT 토큰 생성 
    public String createToken(String userPk, List<String> roles) {
        Claims claims = Jwts.claims().setSubject(userPk); // JWT payload 에 저장되는 정보단위, 보통 여기서 user를 식별하는 값을 넣는다.
        claims.put("roles", roles); // 정보는 key / value 쌍으로 저장된다.
        Date now = new Date();
        return Jwts.builder()
                .setClaims(claims) // 정보 저장
                .setIssuedAt(now) // 토큰 발행 시간 정보
                .setExpiration(new Date(now.getTime() + tokenValidTime)) // set Expire Time
                .signWith(SignatureAlgorithm.HS256, secretKey)  // 사용할 암호화 알고리즘과
                // signature 에 들어갈 secret값 세팅
                .compact();
    }

    // JWT 토큰에서 인증 정보 조회
    public Authentication getAuthentication(String token) {
        UserDetails userDetails = userDetailsService.loadUserByUsername(this.getUserPk(token));
        return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
    }

    // 토큰에서 회원 정보 추출
    public String getUserPk(String token) {
        return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody().getSubject();
    }

    // Request의 Header에서 token 값을 가져옵니다. "Authorization" : "TOKEN값'
    public String resolveToken(HttpServletRequest request) {
        return request.getHeader("Authorization");
    }

    // 토큰의 유효성 + 만료일자 확인
    public boolean validateToken(String jwtToken) {
        try {
            Jws<Claims> claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(jwtToken);
            return !claims.getBody().getExpiration().before(new Date());
        } catch (Exception e) {
            return false;
        }
    }
}

User, UserDetailsService 작성

User

우선 User와 UserDetails로 도메인을 분리하기 싫어서, User에 UserDetails를 상속받았다.


package com.togethersports.tosproejct.user;

@Builder
@Data
@Entity
@Table(name = "T_USER")
@NoArgsConstructor
@AllArgsConstructor
public class User implements UserDetails { //UserDetails는 시큐리티가 관리하는 객체이다.

    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    @Column(name = "USER_SEQUENCE_ID")
    private Long userSequenceId;

    @Column(name = "USER_EMAIL", nullable = false, length = 100, unique = true)
    private String userEmail;

    @Column(name = "USER_BIRTH", length = 6)
    private String userBirth;

    @Column(name = "USER_NICKNAME", length = 15)
    private String userNickname;

    @Column(name = "GENDER", length = 1)
    @Enumerated(EnumType.STRING)
    private Gender gender;

    @Column(name = "ADMIN", length = 4)
    @Enumerated(EnumType.STRING)
    private Admin admin;

    @ElementCollection(fetch = FetchType.EAGER)
    @Builder.Default
    private List<String> roles = new ArrayList<>();


    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return this.roles.stream()
                .map(SimpleGrantedAuthority::new)
                .collect(Collectors.toList());
    }

    @Override
    public String getPassword() {
        return null;
    }

    @Override
    public String getUsername() {
        return userEmail;
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return true;
    }
}

UserDetailsService

@RequiredArgsConstructor
@Service
public class CustomUserDetailService implements UserDetailsService {

    private final UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {

        return userRepository.findByUserEmail(username)
                .orElseThrow(() -> new UsernameNotFoundException("사용자를 찾을 수 없습니다."));
        

    }
}

Test Controller 작성

해당 컨트롤러는 jwt 토큰이 유효한지 판단하는 컨트롤러다.


@RestController
public class TestController {

    @PostMapping("/test")
    public String test(){

        return "<h1>test 통과</h1>";
    }
}

회원가입, 로그인 Controller 작성

임시로 간단하게만 작성했다.

UserController

package com.togethersports.tosproejct.user;

@Slf4j
@RestController
@RequiredArgsConstructor
public class UserController {

    private final JwtTokenProvider jwtTokenProvider;
    private final UserRepository userRepository;

    final String BIRTH = "001200";
    final String EMAIL = "aabbcc@gmail.com";
    final String NICKNAME = "침착맨";
    final Long SEQUENCEID = Long.valueOf(1);
    final Gender GENDER = Gender.;
    final Admin ADMIN = Admin.일반회원;

    User user = User.builder()
            .userEmail(EMAIL)
            .userBirth(BIRTH)
            .userNickname(NICKNAME)
            .admin(ADMIN)
            .gender(GENDER)
            .userSequenceId(SEQUENCEID)
            .roles(Collections.singletonList("ROLE_USER")) // 최초 가입시 USER 로 설정
            .build();


    @PostMapping("/join")
    public String join(){
        log.info("로그인 시도됨");

        userRepository.save(user);


        return user.toString();

    }

    // 로그인
    @PostMapping("/login")
    public String login(@RequestBody Map<String, String> user) {
        log.info("user email = {}", user.get("email"));
        User member = userRepository.findByUserEmail(user.get("email"))
                .orElseThrow(() -> new IllegalArgumentException("가입되지 않은 E-MAIL 입니다."));

        return jwtTokenProvider.createToken(member.getUsername(), member.getRoles());
    }
}

완료 되었다!
포스트맨을 통해서 테스트 해보자.


📌Testing

  1. 포스트맨을 키자.

  2. 회원가입 URL을 날리자

반환값으로 회원정보가 저장된 것을 볼 수 있다.

  1. 로그인을 해보자.

반환값으로 JWT가 넘어온 것을 볼 수 있다.

  1. 토큰을 헤더에 담아서 test를 요청해보자.
  • 성공 경우 : testController가 정상적으로 통과된다.

요청 헤더에 "Authorization"를 추가해주고 토큰 값을 입력해준다.

그리고 "/test"로 요청하자.

정상적으로 반환되었다.

  • 실패 경우 : Forbidden 처리가 된다.

헤더내용을 살짝 조작해보자.

12341234로 조작했다. 다시 요청해보자.

요청이 막힌 것을 볼 수 있다.

성공.


📌JWT Debug

JWT 디버거 사이트

여기서 내 토큰을 디코딩해보자.

내가 담은 정보들이 올바르게 있는 것을 알 수 있다.

다음에는 JWT에 필요한 내용들을 담아보자.


🧷참고자료

profile
관심많은 영호입니다. 궁금한 거 있으시면 다음 익명 카톡으로 말씀해주시면 가능한 도와드리겠습니다! https://open.kakao.com/o/sE6T84kf

7개의 댓글

comment-user-thumbnail
2022년 9월 23일

감사합니다. 잘 봤습니다.

답글 달기
comment-user-thumbnail
2022년 9월 28일

user에 role을 list로 설정하는 이유가 뭔가요?

1개의 답글
comment-user-thumbnail
2022년 11월 29일

안녕하세요. 좋은 정보 감사합니다.
혹시 DB 레이어는 JPA로 구현하신건가요?

1개의 답글
comment-user-thumbnail
2023년 11월 22일

지금 봤네요..
이렇게 코드를 구성하면 token 정보를 받아서 계속 db에서 유효한 사용자인지 조회를 하게 될거같은데요. 그러면 API 날릴때 마다 검증을 하니 db 부하가 많이 있지 않을까요? token에서 db유저 조회 없이 바로 userDetail 객체를 생성할 수 있는 방법은 없나요?

1개의 답글