JWT 토큰과 스프링 시큐리티

kwak woojong·2022년 8월 1일
0

코드스테이츠

목록 보기
31/36
post-thumbnail

1. JWT

JWT는 토큰 형식의 일종임. Json Web Token의 약어다.

이를테면 OAuth2.0 에서 Access-token, Refresh-token이 있는데, 이를 JWT로 표현할 수 있다는 거임.

1. JWT 구조

헤더, 페이로드, 서명으로 구성한다.

1. 헤더 (Header)

헤더는 토큰의 타입, 해시 암호와 알고리즘으로 구성되어 있다.
암호화 알고리즘은 SHA256 같은 친구를 말함.

2. 페이로드 (Payload)

페이로드는 토큰에 담을 정보를 포함하고 있다.
페이로드에 담는 정보의 한 '조각'을 클레임(claim)이라고 부른다.
클레임은 name : value의 한 상으로 이루어져 있다.
Java의 map이나 JavaScript의 객체, Json을 생각하면 편함.
토큰에는 여러개의 클레임을 넣을 수 있다.
클레임의 정보는 등록(registered), 공개(public), 비공개(private) 세 종류가 있다.

3. 서명 (Signature)

secret key를 포함해서 암호화 된 부분임

빨간색이 헤더, 보라색이 페이로드, 파란색이 서명이다.


2. JWT 절차

  1. 클라이언트가 인증 서버에 권한 부여를 요청함. 이는 id, pwd를 통한 로그인이 될 수 있음. 즉 사용자는 권한 서버에 로그인 정보를 보냄.

  2. 권한 서버에서 확인 하고 권한을 부여한다. 이 때 그냥 ㅇㅋ 만 하는게 아니라 JWT 토큰, 액세스 토큰을 클라이언트에게 전달한다.

  3. 클라이언트는 받은 토큰을 api 계층에 줘서 원하는 정보를 찾는다.


3. JWT의 장점

  1. 무상태성과 확장성

    • 서버는 클라이언트에 대한 정보를 저장하지 않는다. 그냥 토큰이 해독이 되는지만 확인함.
    • 클라이언트는 새 요청을 보낼 때마다 토큰을 헤더에 포함 시키기만 하면 된다. 같은 토큰으로 여러 서버에서 인증이 가능하기 때문에, 서버를 여러개 가지고 있는 서비스라면 매우 큰 장점임. (세션의 경우 각 서버가 모두 해당 유저의 정보를 공유하고 있어야 한다)
  2. 보안성

    • 암호화 키를 노출할 필요가 없기 때문에 안전하다.
  3. 어디서나 생성 가능

    • 토큰을 어디서나 만들 수 있다.
  4. 권한 부여가 편하다.

    • 토큰의 페이로드 안에 해당 유저가 어떤 정보에 접근 가능한지 정할 수 있다.

4. JWT의 단점

  1. 페이로드가 보안에 쫌 약함
    • 페이로드에 앵간하면 중요한 정보를 넣지 말아야함.
  2. 토큰의 길이가 무진장 길 수 있음
    • 길어지면 그만큼 필요 메모리가 많아진다. 한 두명이야 괜찮은데, 사람이 많아지면 이것도 네트워크에 무리를 줄 수 있음.
  3. 자동 삭제가 안됨
    • 이는 토큰 만료시간을 조절함으로써 해결 할 수 있음. 이 토큰 만료 시간을 적절히 잘 조율해야함.
  4. 어딘가 저장되긴 해야함.
    • 클라이언트가 토큰을 가지고 있다가 헤더에 넣어서 보내야함.

JWT 적용

1. Gradle 추가

implementation 'com.auth0:java-jwt:3.19.2'

JWT 적용을 위해 외부 라이브러리를 Gradle에 추가해주자.

https://github.com/auth0/java-jwt

요기가 jwt 깃헙 주소다. 버전 정보 같은거도 잘 돼 있으니까 가서 확인 ㄱㄱ

2. 시큐리티 설정

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final MemberRepository memberRepository;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.csrf().disable();
        http.headers().frameOptions().disable();
        http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .formLogin().disable()
                .httpBasic().disable()
                .apply(new CustomDsl()) // 추가
                .and()
                .authorizeRequests()
                .antMatchers("/api/v1/user/**")
                .access("hasRole('ROLE_USER') or hasRole('ROLE_MANAGER') or hasRole('ROLE_ADMIN')")
                .antMatchers("/api/v1/manager/**")
                .access("hasRole('ROLE_MANAGER') or hasRole('ROLE_ADMIN')")
                .antMatchers("/api/v1/admin/**")
                .access("hasRole('ROLE_ADMIN')")
                .anyRequest().permitAll();
        return http.build();
    }

    public class CustomDsl extends AbstractHttpConfigurer<CustomDsl, HttpSecurity> {

        @Override
        public void configure(HttpSecurity builder) throws Exception {
            AuthenticationManager authenticationManager = builder.getSharedObject(AuthenticationManager.class);
            builder
                    .addFilter(new JwtAuthenticationFilter(authenticationManager))
                    .addFilter(new JwtAuthorizationFilter(authenticationManager, memberRepository)); // 추가

        }
    }
}

MemberRepository는 Member 만들면서 작성하고

CustomDsl의 JwtAuthenticationFilter, JwtAuthorizationFilter도 만들거임.

3. 클래스 개발

1. Member

@Getter
@Setter
@Entity
@NoArgsConstructor
@AllArgsConstructor
public class Member {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String username;
    private String password;
    private String roles; // User, MANAGER, ADMIN

    public List<String> getRoleList() {
        if(this.roles.length() > 0) {
            return Arrays.asList(this.roles.split(","));
        }
        return new ArrayList<>();
    }
}

롤 리스트 메서드는 상황에 따라 버려도 된다.
roles를 읽어서 인가 절차를 밟아야 함. 헷갈리면 무시 ㄱ

2. PrincipalDetails

Member를 이용해서 UserDetails를 만들어야 한다. 시큐리티는 이걸 기준으로 로그인 처리를 도와줌. 처리 흐름도를 참고하셈

@Getter
public class PrincipalDetails implements UserDetails {

    private Member member;

    public PrincipalDetails(Member member) {
        this.member = member;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        Collection<GrantedAuthority> authorities = new ArrayList<>();
        member.getRoleList().forEach(e -> authorities.add(() -> e));
        return authorities;
    }

    @Override
    public String getPassword() {
        return member.getPassword();
    }

    @Override
    public String getUsername() {
        return member.getUsername();
    }

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

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

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

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

인가 처리를 위한 authority 부분이 헷갈릴 수 있겠다. 저기엔 이 Member가 가진 roles를 넣어주는 부분이라고 생각하면 된다. 롤이 한개면 그냥 한개만 잘 박자. Enum일 가능성이 많아 보인다.

3. PrincipalDetailsService

UserDetails를 잘 만들어 놨으면 이제 그 친구를 어떻게 만들 것인지 서비스 계층을 만들어야 한다.

@Service
@RequiredArgsConstructor
public class PrincipalDetailsService implements UserDetailsService {

    private final MemberRepository memberRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        Member member = memberRepository.findByUsername(username);
        return new PrincipalDetails(member);
    }
}

간단하게 만들어 준다. 스프링 시큐리티가 이 친구로 PrincipalDetails를 생성할거임. (UserDetails)

생성된 UserDetails는 AuthenticationManager 에 전달 될 거고 요기 있는 UserDeatils를 뽑아서 AuthenticationFilter에서 토큰을 만들어서 넣어주면 된다.

4. AuthenticationFilter

로그인 시도시 HTTP 요청으로 Member의 username과 password가 넘어올 거다. 이 두 놈 잡아서 넘기면 된다. 여기선 formLogin은 안썻음 걍 json으로 넘어왔다고 치자.

@RequiredArgsConstructor
@Slf4j
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {

    private final AuthenticationManager authenticationManager;

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
        System.out.println("======로그인 시도 ======");

        try {
            ObjectMapper om = new ObjectMapper();
            Member member = om.readValue(request.getInputStream(), Member.class);
            UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(member.getUsername(), member.getPassword());
            Authentication authenticate = authenticationManager.authenticate(authenticationToken);
//            PrincipalDetails principalDetails = (PrincipalDetails) authenticate.getPrincipal();
            return authenticate;

        } catch (IOException e) {
            log.error("에러 발생!! = {}");
            e.printStackTrace();
        }
        return null;
    }

    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {

        System.out.println("======인증 성공======");
        PrincipalDetails principalDetails = (PrincipalDetails) authResult.getPrincipal();

        String jwtToken = JWT.create()
                .withSubject("cos jwt token")
                .withExpiresAt(new Date(System.currentTimeMillis() + (60 * 1000 * 10)))
                .withClaim("id", principalDetails.getMember().getId())
                .withClaim("username", principalDetails.getMember().getUsername())
                .sign(Algorithm.HMAC512("cos_jwt_token"));
        response.addHeader("Authorization", "Bearer " + jwtToken);
    }
}

successfulAuthentication()의 경우 로그인 인증이 성공했을 경우 발동하는 메서드다. 실패할 경우엔 발동하지 않음. 그러니까 여기서 JWT.create를 써서 JWT를 만들어주자.

withSubject는 일종의 식별자 역할
withExpiresAt은 토큰 만료시간 설정이다. ms단위이므로 1000을 곱해놔야 계산하기 편해짐. 쟤는 600초다.
withClaim은 JWT의 클레임임. name, value 쌍으로 넣어줘야 한다. 여기선 member의 Id와 Username을 넣어줬다.
.sign은 JWT의 시그니쳐(서명) 부분, HMAC512의 "cos_jwt_token"으로 암호화 해준다라는 뜻.

다 만들어진 토큰을 헤더에 Authorization라는 이름으로 보낸다. 나중에 인가처리가 필요하면, Authorization 을 뜯어와서 해독하면 된다.

Bearer 는 일종의 타입을 뜻함. 얘는 JWT를 쓰고 있어요! 혹은 OAuth 토큰을 쓰고 있어요! 라고 알려주는 것 같음.


5. AuthorizationFilter

public class JwtAuthorizationFilter extends BasicAuthenticationFilter {

    private MemberRepository memberRepository;

    public JwtAuthorizationFilter(AuthenticationManager authenticationManager, MemberRepository memberRepository) {
        super(authenticationManager);
        this.memberRepository = memberRepository;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
        System.out.println("======인증 권한이나 권한이 필요한 주소 요청됨 ======");

        String jwtHeader = request.getHeader("Authorization");

        if (jwtHeader == null || !jwtHeader.startsWith("Bearer")) {
            chain.doFilter(request, response);
            return;
        }

        String jwtToken = jwtHeader.replace("Bearer ", "");

        String username = JWT.require(Algorithm.HMAC512("cos_jwt_token")).build().verify(jwtToken).getClaim("username").asString();

        if (username != null) {
            Member member = memberRepository.findByUsername(username);
            PrincipalDetails principalDetails = new PrincipalDetails(member);
            Authentication authentication = new UsernamePasswordAuthenticationToken(principalDetails, null, principalDetails.getAuthorities());
            SecurityContextHolder.getContext().setAuthentication(authentication);

            chain.doFilter(request, response);
        }

        super.doFilterInternal(request, response, chain);
    }
}

인증 처리 된 사용자가 뭔가 권한 요청을 하고 있다? 그럼 얘가 발동된다.

Http요청에서 Authorization을 뜯어온다. 그리고 Bearer로 시작하냐? 혹은 없니? 를 체크하고 없으면 끝내고, 있으면 다음 로직으로 넘긴다.

JWT를 해석해서 username 클레임을 찾고 값을 뽑는다. 또 그 값이 null인지 체크. null이 아니면 해당 유저가 가진 롤을 담고 있는 토큰을 만들어서 SecurityContextHolder에 인증을 추가해줌.

포스트맨으로 테스트 해보면 SecurityConfig에서 설정한 대로 권한이 없으면 403을 보내주고, 있으면 정상적으로 해당 api를 작동해준다.


뭔가 적다보니까 복잡하게 적은거 같음.

하여튼 인터넷 좀 뒤지고 문서 좀 보고 하면 할만 함.

profile
https://crazyleader.notion.site/Crazykwak-36c7ffc9d32e4e83b325da26ed8d1728?pvs=4<-- 포트폴리오

0개의 댓글