Spring security + JWT토큰

안세웅·2023년 4월 25일
2

Spring Boot

목록 보기
2/3
post-thumbnail

JWT(Json Web Token) 토큰 이란?

JWT 토큰은 웹에서 사용되는 JSON 형식의 토큰에 대한 표준 규격 입니다.
주로 사용자의 인증(authentication) 또는 인가(authorization) 정보를 서버와 클라이언트 간에 안전하게 주고 받기 위해서 사용됩니다.


JWT 토큰 구조

  • Header, Payload, Signature의 3부분으로 이루어져 있습니다.
  • Json 형태인 각 부분은 Base64로 인코딩 되어 표현되며 각 부분은 . 로 연결되어 있습니다.

JWT 토큰을 Decode 해보면 아래와 같이 3부분의 정보를 확인 할 수 있습니다.

JWT 토큰 Decode 사이트 : https://jwt.io/

header 부분에는 토큰의 종류와 해싱 알고리즘을 가지고 있습니다.

{ 
 "alg": "HS256",
 "typ": JWT
}

alg : 해싱 알고리즘
typ : 토큰의 종류

Payload

payload 부분에는 토큰에 담을 정보가 들어있습니다.
이 정보들을 클레임(Claim) 이라고 부르고, 이는 Json(Key/Value) 형태의 한 쌍으로 이루어져 있습니다.

{
  "sub": "1234567890",
  "name": "John Doe",
  "iat": 1516239022
}

클레임에는 registered(등록), public(공개), private(비공개) 세가지 유형이 있습니다.

Registered claims(등록 클레임) : 등록된 클레임들은 서비스에서 필요한 정보들이 아닌, 토큰에 대한 정보들을 담기위하여 이름이 이미 정해진 클레임들입니다.

iss(issuer) : 토큰 발급자
exp(expiration time) : 토큰 만료 시간
sub(subject) : 토큰 제목
aud(audience) : 토큰 수신자/대상자
nbf(Not Before) : 토큰의 활성 날짜
iat(issued at): 토큰이 발급된 시간, 토큰의 age가 얼마나 되었는지 판단 가능
jti : JWT의 고유 식별자, 주로 중복 처리 방지를 위해 사용, 일회용 토큰에 사용하면 유용

Public claims(공개 클레임) : JWT를 사용하는 사용자가 원하는 대로 정의할 수 있다. 그러나 충돌을 방지하려면 IANA JSON Web Token Registry에 정의하거나 충돌 방지 네임스페이스를 포함하는 URI로 정의해야합니다.

Private Claims(비공개 클레임) : 이러한 claim은 이용에 동의하고, Registered claims도, Public claims도 아닌 당사자들 간에 정보를 공유하기 위해 만들어진 custom claim이다. Public claims과는 달리 이름이 중복되어 충돌 가능성이 있습니다.

Signature

서명 부분을 생성하려면 인코딩된 Header, 인코딩된 Payload, 암호, Header에 지정된 알고리즘을 가져와서 서명해야한다.

Header의 인코딩 값과 Data의 인코딩 값을 합친 후 주어진 private key로 해시하여 생성한다.

서명은 메시지가 도중에 변경되지 않았는지 확인하는데 사용되며, private key로 서명된 토큰의 경우 JWT의 송신자가 누구인지 확인할 수도 있다.

HMACSHA256(
  base64UrlEncode(header) + "." +
  base64UrlEncode(payload),
secret)

Spring security + JWT토큰 동작원리

  1. 클라이언트에서 ID/PW 로 로그인 요청

  2. 서버에서 해당 ID/PW 를 가진 사용자가 있을 경우 Access Token과 Refresh Token 발급

  3. 클라이언트에서 API 요청 시 헤더에 Access Token을 담아서 요청

Refresh Token

Refresh Token 은 새로운 Access Token을 발급하기 위한 토큰입니다.

Access Token은 외부 유출 문제로 인해 유효기간을 짧게 설정하고
Refresh Token의 유효기간은 Access Token의 유효기간보다 길게 설정 합니다.

Access Token + Refresh Token 재발급

  1. 클라이언트에서 API 요청 시 Access Token이 만료되었을 경우 만료되었다고 응답

  2. 클라이언트에서 Access Token 재발급 요청

  3. 서버에서 요청을 받아 Refresh Token 검증 후 Access Token + Refresh Token 재발급


구현

build.gradle

아래 라이브러리 추가

dependencies {

    //spring security
	implementation 'org.springframework.boot:spring-boot-starter-security'
 
	// jwt
	implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
	implementation 'io.jsonwebtoken:jjwt-impl:0.11.5'
	implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5'
}

TokenInfo

클라이언트에 전달할 토큰 DTO 생성

@Builder
@Data
@AllArgsConstructor
public class TokenInfo {
 
    private String grantType;
    private String accessToken;
    private String refreshToken;
}

JwtTokenProvider

JWT토큰 생성, 복호화, 정보추출, 검증을 구현할 클래스

jwt.secret=VlwEyVBsYt9V7zq57TejMnVUyzblYcfPQye08f7MGVA9XkHa

토큰 암호화 복호화 하기 위한 secret key 를 application.properties 에 추가합니다.

@Slf4j
@Component
public class JwtTokenProvider {

    private final Key key;

    public JwtTokenProvider(@Value("${jwt.secret}") String secretKey) {
        byte[] keyBytes = Decoders.BASE64.decode(secretKey);
        this.key = Keys.hmacShaKeyFor(keyBytes);
    }

    // AccessToken, RefreshToken 을 생성하는 메서드
    public TokenInfo generateToken(Authentication authentication) {
        // 권한 가져오기
        String authorities = authentication.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.joining(","));

        long now = (new Date()).getTime();
        // Access Token 생성
        // Date 생성자에 삽입하는 숫자 86480000 
        // ->  30분: 30(m) * 60(s) * 1000(ms) = 1800000
        // ->  1일: 24(h) * 60(m) * 60(s) * 1000(ms) = 86400000
        Date accessTokenExpiresIn = new Date(now + 1800000);
        String accessToken = Jwts.builder()
                .setSubject(authentication.getName())
                .claim("auth", authorities)
                .claim("member_code", ((UserCustom)authentication.getPrincipal()).getMemberCode())
                .setExpiration(accessTokenExpiresIn)
                .signWith(key, SignatureAlgorithm.HS256)
                .compact();

        // Refresh Token 생성
        String refreshToken = Jwts.builder()
                .claim("member_code", ((UserCustom)authentication.getPrincipal()).getMemberCode())
                .setExpiration(new Date(now + 86400000))
                .signWith(key, SignatureAlgorithm.HS256)
                .compact();

        return TokenInfo.builder()
                .grantType("Bearer")
                .accessToken(accessToken)
                .refreshToken(refreshToken)
                .build();
    }

    // JWT 토큰을 복호화하여 토큰에 들어있는 정보를 꺼내는 메서드
    public Authentication getAuthentication(String accessToken) {
        // 토큰 복호화
        Claims claims = parseClaims(accessToken);

        if (claims.get("auth") == null) {
            throw new RuntimeException("권한 정보가 없는 토큰입니다.");
        }

        // 클레임에서 권한 정보 가져오기
        Collection<? extends GrantedAuthority> authorities =
                Arrays.stream(claims.get("auth").toString().split(","))
                        .map(SimpleGrantedAuthority::new)
                        .collect(Collectors.toList());

        // UserDetails 객체를 만들어서 Authentication 리턴
        UserCustom principal = new UserCustom(claims.getSubject(), "", authorities, (Integer)claims.get("member_code"));
        return new UsernamePasswordAuthenticationToken(principal, "", authorities);
    }

    public Integer getMemberCodeByRefreshToken(String refreshToken){
        Claims claims = parseClaims(refreshToken);
        return (Integer)claims.get("member_code");
    }

    // 토큰 정보를 검증하는 메서드
    public boolean validateToken(String token) {
        try {
            Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
            return true;
        } catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
            log.info("Invalid JWT Token", e);
        } catch (ExpiredJwtException e) {
            log.info("Expired JWT Token", e);
        } catch (UnsupportedJwtException e) {
            log.info("Unsupported JWT Token", e);
        } catch (IllegalArgumentException e) {
            log.info("JWT claims string is empty.", e);
        }
        return false;
    }

    private Claims parseClaims(String accessToken) {
        try {
            return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(accessToken).getBody();
        } catch (ExpiredJwtException e) {
            return e.getClaims();
        }
    }
}

JwtAuthenticationFilter

클라이언트 요청 시 JWT 인증을 하기 위해 설치하는 커스텀 필터

@RequiredArgsConstructor
public class JwtAuthenticationFilter extends GenericFilterBean {
 
    private final JwtTokenProvider jwtTokenProvider;
 
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
 
        // 1. Request Header 에서 JWT 토큰 추출
        String token = resolveToken((HttpServletRequest) request);
 
        // 2. validateToken 으로 토큰 유효성 검사
        if (token != null && jwtTokenProvider.validateToken(token)) {
            // 토큰이 유효할 경우 토큰에서 Authentication 객체를 가지고 와서 SecurityContext 에 저장
            Authentication authentication = jwtTokenProvider.getAuthentication(token);
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }
        chain.doFilter(request, response);
    }
 
    // Request Header 에서 토큰 정보 추출
    private String resolveToken(HttpServletRequest request) {
        String bearerToken = request.getHeader("Authorization");
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer")) {
            return bearerToken.substring(7);
        }
        return null;
    }
}

SecurityConfig

Spring Security 설정을 위한 클래스이다.

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtTokenProvider jwtTokenProvider;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                // .httpBasic().disable().csrf().disable(): rest api이므로 basic auth 및 csrf 보안을 사용하지 않는다는 설정
                .httpBasic().disable().csrf().disable()

                // .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS): JWT를 사용하기 때문에 세션을 사용하지 않는다는 설정
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)

                .and()
                .authorizeRequests()

                // 정적자원에 대해서 요청을 허가한다는 설정
                .requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll()

                .antMatchers("/api/**").authenticated()

                /* 
                .antMatchers("호출 URL").permitAll(): 해당 API에 대해서는 모든 요청을 허가한다는 설정
                .antMatchers("호출 URL").authenticated(): 해당 API 에 대해 인증을 필요로 한다는 설정
                .antMatchers("호출 URL").hasRole("권한명"): 특정 권한이 있어야 요청할 수 있다는 설정
                .antMatchers("호출 URL").hasAnyRole("권한명1", "권한명2"): 권한1 또는 권한2 이 있어야 요청할 수 있다는 설정
                .anyRequest().authenticated(): 이 밖에 모든 요청에 대해서 인증을 필요로 한다는 설정
                */
                .and()
                /* 
                addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class)
                : JWT 인증을 위하여 직접 구현한 필터를 UsernamePasswordAuthenticationFilter 전에 실행하겠다는 설정
                 */
                .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class);


        http.headers().frameOptions().sameOrigin();

        return http.build();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }
}

Member

인증을 위한 사용자 도메인

@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "member")
public class Member implements UserDetails {
    @Id
    @Column(name = "member_code", nullable = false)
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Integer memberCode;

    @Column(name = "member_id", nullable = false)
    private String memberId;

    @Column(nullable = false)
    private String password;

    @Column(nullable = false)
    private String email;

    @Column(nullable = false)
    private String salt;

    @Column(name = "refresh_token")
    private String refreshToken;

    @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 getUsername() {
        return memberId;
    }

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

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

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

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

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

MemberRepository

public interface MemberRepository extends JpaRepository<Member, Integer> {
    Optional<Member> findByMemberId(String id);

    @Query(value = "select salt from member where member_id = :id", nativeQuery = true)
    String getSalt(@Param("id") String id);
}

MemberService

토큰 발급 메서드 구현

@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class MemberService {
    private final MemberRepository memberRepository;
    private final AuthenticationManagerBuilder authenticationManagerBuilder;
    private final JwtTokenProvider jwtTokenProvider;

    @Transactional
    public TokenInfo getToken(String memberId, String password) throws Exception{

        String salt = memberRepository.getSalt(memberId);

        if(salt == null || salt == "") {
            throw new Exception("해당하는 유저를 찾을 수 없습니다.");
        }

        // 1. Login ID/PW 를 기반으로 Authentication 객체 생성
        // 이때 authentication 는 인증 여부를 확인하는 authenticated 값이 false
        UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(memberId, getSecurePassword(password, salt));

        // 2. 실제 검증 (사용자 비밀번호 체크)이 이루어지는 부분
        // authenticate 매서드가 실행될 때 CustomUserDetailsService 에서 만든 loadUserByUsername 메서드가 실행
        Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);

        // 3. 인증 정보를 기반으로 JWT 토큰 생성
        TokenInfo tokenInfo = jwtTokenProvider.generateToken(authentication);

        // 4. refreshToken DB에 저장
        Member member = memberRepository.findByMemberId(memberId).get();
        member.setRefreshToken(tokenInfo.getRefreshToken());
        memberRepository.save(member);

        return tokenInfo;
    }
    
    public String getSecurePassword(String password, String salt) throws NoSuchAlgorithmException {

        String passwordAndSalt = password + salt;

        MessageDigest md = MessageDigest.getInstance("SHA-256");

        // 암호화
        md.update(passwordAndSalt.getBytes());
        return String.format("%064x", new BigInteger(1, md.digest()));
    }
}

토큰 발급 과정은
1. 로그인 요청으로 들어온 memberId로 해당 계정의 salt를 조회
2. 해당계정이 없으면 Exception 반환
3. memberId와 비밀번호 + salt로 암호화 한 비밀번호로 Authentication 객체를 생성
4. authenticate() 메서드를 통해 요청된 Member에 대해 검증
5. 검증 완료 시 새로운 토큰 생성 후 Refresh Token DB에 저장

현재 비밀번호 임의의 문자열인 salt와 조합하여 암호화 한 비밀번호를 사용하고 있으나
테스트를 위해 제외하고 진행하여도 무방합니다.

CustomUserDetailsService

@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {

    private final MemberRepository memberRepository;
    private final PasswordEncoder passwordEncoder;

    @Override
    public UserDetails loadUserByUsername(String id) throws UsernameNotFoundException {
        return memberRepository.findByMemberId(id)
                .map(this::createUserDetails)
                .orElseThrow(() -> new UsernameNotFoundException("해당하는 유저를 찾을 수 없습니다."));
    }

    // 해당하는 User 의 데이터가 존재한다면 UserDetails 객체로 만들어서 리턴
    private UserDetails createUserDetails(Member member) {

        UserCustom userCustom = new UserCustom(member.getUsername()
                , passwordEncoder.encode(member.getPassword()), authorities(member.getRoles())
                , member.getMemberCode()
        );

        return userCustom;
    }

    private static Collection authorities(List<String> roles){
        Collection authorities = new ArrayList<>();
        for (String role : roles) {
            authorities.add(new SimpleGrantedAuthority("ROLE_" + role));
        }
        return authorities;
    }
}

UserCustom

사용자 정보를 가져올 객체

@Getter
@Setter
@ToString
public class UserCustom extends User {
    private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID;

    // 유저의 정보를 더 추가하고 싶다면 이곳과, 아래의 생성자 파라미터를 조절해야 한다.
    private Integer memberCode;

    public UserCustom(String username, String password, Collection authorities, Integer member_code) {
        super(username, password, authorities);
        this.memberCode = member_code;
    }
}

MemberLoginRequestDto

로그인 요청을 받을 객체

@Data
public class MemberLoginRequestDto {
    private String memberId;
    private String password;
}

MemberController

@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping("/member")
public class MemberController {

    @Autowired
    private final MemberService memberService;
    @PostMapping("/login")
    public ResponseEntity login(@RequestBody MemberLoginRequestDto memberLoginRequestDto, HttpServletResponse response) {

        JSONObject resJobj = new JSONObject();
        try {
            String memberId = memberLoginRequestDto.getMemberId();
            String password = memberLoginRequestDto.getPassword();
            TokenInfo tokenInfo = memberService.getToken(memberId, password);

            createCookie("refreshToken", tokenInfo.getRefreshToken(), response);
            resJobj.put("status", "ERROR");
            resJobj.put("grantType", tokenInfo.getGrantType());
            resJobj.put("accessToken", tokenInfo.getAccessToken());
            return new ResponseEntity(resJobj, HttpStatus.OK);
        }
        catch (Exception e){

            resJobj.put("status", "ERROR");
            resJobj.put("message", e.getMessage());
            return new ResponseEntity(resJobj, HttpStatus.BAD_REQUEST);
        }
}

DB

테스트를 위해 각 컬럼에 맞는 데이터 생성


테스트

이후 postman 으로 로그인 테스트를 해보면

정상적으로 Access Token과 Refresh Token 이 발급되는 것을 볼 수 있습니다.

또한 발급 받은 Access Token을 헤더에 담아 API 호출 시 정상적으로 작동하는 것을 확인 할 수 있습니다.



Reference

https://gksdudrb922.tistory.com/217

0개의 댓글