📚 관련 독서/스프링 부트 3 백엔드 개발자 되기 - 자바 편

[SpringBoot] 09장 JWT ver. 로그인/로그아웃 구현

imname1am 2024. 2. 13. 23:55
반응형

📗 토큰 기반 인증

⭐ 토큰 기반 인증

인증 정보를 서버/세션에 저장하지 않고, 클라이언트 측에서 발급받은 토큰으로 인증하는 방식

* 토큰이란? 서버에서 클라이언트를 구분하기 위한 유일한 값

 

 

토큰을 전달하고 인증받는 과정

- 서버가 토큰을 생성해 클라이언트에게 제공하면, 클라이언트는 이 토큰을 갖고 있다가 요청할 때마다 요청 내용과 함께 토큰을 전송한다. (= 토큰을 갖는 주체 : 클라이언트)

 

 

토큰 기반 인증 특징

무상태성

사용자의 인증 정보가 담긴 토큰이 서버가 아닌 클라이언트에 있어 서버에 저장할 필요가 없음을 의미.

- 상태 관리 : 클라이언트에서 사용자의 인증 상태를 유지하면서 이후 요청을 처리하는 것 → 서버는 무상태로 효율적인 검증 가능

 

 

확장성

토큰을 가지는 주체가 클라이언트이므로 갖고 있는 하나의 토큰으로 여러 서버에 동시에 요청 전송 가능.

- 토큰 기반 인증을 사용하는 다른 시스템에 접근할 수도 있고, 이를 활용해 다른 서비스에 권한을 공유할 수도 있음.

 

 

무결성

토큰 발급 이후, 토큰 정보 변경 불가!

 

vs 세션 인증
세션 인증 기반은 각각 API에서 인증해야 함

 

⭐ JWT

토큰 기반 인증에서 주로 사용하는 토큰으로, JSON 형식으로 클라이언트 정보 저장

- 발급받은 JWT 이용해 인증하는 법 : HTTP 요청 헤더 중 Authorization 키 값에 Bearer + JWT 토큰값 넣어 보냄

 

📌 JWT 구조 : . 기준 헤더-내용-서명으로 구성

[헤더] 토큰 타입(typ)해싱 알고리즘 지정(alg)하는 정보 저장

{
    "typ": "JWT",	// typ : 토큰 타입 지정
    "alg": "HS256"	// alg : 해싱 알고리즘 지정
}

 

[내용] 토큰에 담을 정보 저장

* 클레임 : 내용의 한 덩어리. 키값의 한 쌍으로 구성. (등록된 / 공개 / 비공개 클레임)

 

1) 등록된 클레임 : 토큰에 대한 정보 담을 때 사용 (iss, sub, aud, exp, nbg, iat, jti)

 

2) 공개 클레임 : 공개되어도 상관없는 클레임. 충돌 X & URI로 지음

3) 비공개 클레임공개되면 안 되는 클레임. 클라이언트-서버 간 통신에 사용

 

[서명]: 해당 토큰이 조작/변경되지 않았음을 확인하는 용도로 사용. 헤더의 인코딩 값 + 내용의 인코딩 값 한 후 주어진 비밀키를 사용해 해시값 생성

 

 

토큰 유효기간

리프레시 토큰 

: 사용자 인증용이 아닌 액세스 토큰 만료 시 새 액세스 토큰을 발급하기 위해 사용하는 토큰

 

 

📗 JWT 서비스 구현하기

1. 의존성 추가하기 (build.gradle)

testAnnotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.projectlombok:lombok'
implementation 'io.jsonwebtoken:jjwt:0.9.1'     // 자바 JWT 라이브러리
implementation 'javax.xml.bind:jaxb-api:2.3.1'  // XML 문서와 Java 객체 간 매핑 자동화

 

2. 토큰 제공자 추가하기

JWT 생성하고 유효한 토큰인지 검증하는 역할 하는 클래스 추가하기

 

1) issuer, secret_key를 필수로 설정해야 한다. (application.yml)

jwt:
  issuer: [emial주소]
  secret_key: study-springboot

 

 

2) 1에서 만든 값들을 변수로 접근하는 데 사용할 JwtProperties 클래스 생성하기 (config/jwt/JwtProperties.java)

@Setter
@Getter
@Component
@ConfigurationProperties("jwt") // 자바 클래스에 프로퍼티 값 가져와 사용하는 애너테이션
public class JwtProperties {
    private String issuer;
    private String secretKey;
}

 

 

3) 토큰 생성하고 올바른 토큰인지 유효성 검사하고, 토큰에서 필요한 정보 가져오는 클래스 작성하기 (config/jwt/TokenProvider.java)

@RequiredArgsConstructor
@Service
public class TokenProvider {

    private final JwtProperties jwtProperties;

    public String generateToken(User user, Duration expiredAt) {
        Date now = new Date();
        return makeToken(new Date(now.getTime() + expiredAt.toMillis()), user);
    }

    // 1) JWT 토큰 생성 메서드
    private String makeToken(Date expiry, User user) {
        Date now = new Date();

        return Jwts.builder()
                .setHeaderParam(Header.TYPE, Header.JWT_TYPE)   // 헤더 typ : JWT
                // [헤더]
                .setIssuer(jwtProperties.getIssuer())   // 내용  iss : [email주소] (properties 파일에서 설정한 값)
                .setIssuedAt(now)                   // 내용 iat : 현재 시간
                .setExpiration(expiry)              // 내용 exp : expiry 멤버 변수값
                .setSubject(user.getEmail())        // 내용 sub : 유저 이메일
                .claim("id", user.getId())    // [내용] 클레임 id : 유저 ID
                // 서명 : 비밀값과 함께 해시값을 HS256 방식으로 암호화
                .signWith(SignatureAlgorithm.HS256, jwtProperties.getSecretKey())
                .compact();
    }

    // 2) JWT 토큰 유효성 검증 메서드
    public boolean validToken(String token) {
        try {
            Jwts.parser()
                    .setSigningKey(jwtProperties.getSecretKey())    // 비밀값으로 복호화
                    .parseClaimsJws(token);

            return true;
        } catch (Exception e) { // 복호화 과정에서 에러 나면 유효하지 않은 토큰
            return false;
        }
    }

    // 3) 토큰 기반으로 인증 정보 가져오는 메서드
    public Authentication getAuthentication(String token) {
        Claims claims = getClaims(token);
        Set<SimpleGrantedAuthority> authorities = Collections.singleton(new SimpleGrantedAuthority("ROLE_USER"));

        return new UsernamePasswordAuthenticationToken(new org.springframework.security.core.userdetails.User(claims.getSubject(), "", authorities), token, authorities);
    }

    // 4) 토큰 기반으로 유저 ID 가져오는 메서드
    public Long getUserId(String token) {
        Claims claims = getClaims(token);
        return claims.get("id", Long.class);
    }

    // 클레임 가져오는 메서드
    private Claims getClaims(String token) {
        return Jwts.parser()    // 클레임 조회
                .setSigningKey(jwtProperties.getSecretKey())
                .parseClaimsJws(token)
                .getBody();
    }
}

 

 

4) 제대로 동작하는지 테스트 코드 작성하기 (test/config/jwt/JwtFactory.java)

 

 

빌더 패턴 : 객체 생성 시 테스트가 필요한 데이터만 선택 (사용 안 하면 필드 기본값 사용)

JwtFactory.java 코드 확인하기
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
32
33
34
@Getter
public class JwtFactory {
 
    private String subject = "test@email.com";
    private Date issuedAt = new Date();
    private Date expiration = new Date(new Date().getTime() + Duration.ofDays(14).toMillis());
    private Map<String, Object> claims = Collections.emptyMap();
 
    // 빌더 패턴 사용해 설정이 필요한 데이터만 선택 설정
    @Builder
    public JwtFactory(String subject, Date issuedAt, Date expiration, Map<String, Object> claims) {
        this.subject = subject != null ? subject : this.subject;
        this.issuedAt = issuedAt != null ? issuedAt : this.issuedAt;
        this.expiration = expiration != null ? expiration : this.expiration;
        this.claims = claims != null ? claims : this.claims;
    }
 
    public static JwtFactory withDefaultValues() {
        return JwtFactory.builder().build();
    }
 
    // jjwt 라이브러리 사용해 JWT 토큰 생성
    public String createToken(JwtProperties jwtProperties) {
        return Jwts.builder()
                .setSubject(subject)
                .setHeaderParam(Header.TYPE, Header.JWT_TYPE)
                .setIssuer(jwtProperties.getIssuer())
                .setIssuedAt(issuedAt)
                .setExpiration(expiration)
                .addClaims(claims)
                .signWith(SignatureAlgorithm.HS256, jwtProperties.getSecretKey())
                .compact();
    }
}
cs
 

 

 

4) TokenProviderTest 클래스 테스트하는 클래스 만들기 (test/config/jwt/TokenProviderTest.java)

 

TokenProvierTest.java 코드 확인하기
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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
@SpringBootTest
public class TokenProviderTest {
    @Autowired
    private TokenProvider tokenProvider;
 
    @Autowired
    private UserRepository userRepository;
 
    @Autowired
    private JwtProperties jwtProperties;
 
    // 1) generateToken() 검증 테스트 - 토큰 생성 메서드 테스트
    @DisplayName("generateToken(): 유저 정보와 만료 기간을 전달해 토큰을 만들 수 있다.")
    @Test
    void generateToken() {
        // [given] 토큰에 유저 정보 추가 위한 테스트 유저 생성
        User testUser = userRepository.save(User.builder()
                .email("user@gmail.com")
                .password("test")
                .build());
 
        // [when] 토큰 제공자의 메서드 호출해 토큰 생성
        String token = tokenProvider.generateToken(testUser, Duration.ofDays(14));
 
        // [then] jjwt 라이브러리 사용해 토큰 복호화.
        Long userId = Jwts.parser()
                .setSigningKey(jwtProperties.getSecretKey())
                .parseClaimsJws(token)
                .getBody()
                .get("id", Long.class);
 
        assertThat(userId).isEqualTo(testUser.getId());
    }
 
    // 2) validToken() 검증 테스트 - 토큰이 유효한지 검증하는 메서드 테스트
    @DisplayName("invalidToken(): 만료된 토큰인 때에 유효성 검증에 실패한다..")
    @Test
    void validToken_invalidToken() {
        // [given] jjwt라이브러리 사용해 이미 만료된 토큰 생성
        String token = JwtFactory.builder()
                .expiration(new Date(new Date().getTime() - Duration.ofDays(7).toMillis()))
                .build()
                .createToken(jwtProperties);
 
        // [when] 유효한 토큰인지 검증 후 결과값 받음
        boolean result = tokenProvider.validToken(token);
 
        // [then] 유효한 토큰이 아님(false)을 확인
        assertThat(result).isFalse();
    }
 
    @DisplayName("validToken(): 유효한 토큰인 때에 유효성 검증에 성공한다..")
    @Test
    void validToken_validToken() {
        // [given] jjwt라이브러리 사용해 만료되지 않은 토큰 생성 (14일 후)
        String token = JwtFactory.withDefaultValues()
                .createToken(jwtProperties);
 
        // [when] 유효한 토큰인지 검증 후 결과값 받음
        boolean result = tokenProvider.validToken(token);
 
        // [then] 유효한 토큰임(true)을 확인
        assertThat(result).isTrue();
    }
 
    // 3) getAuthentication() 검증 테스트 - 토큰을 전달받아 인증 정보 담은 객체 Authentication 반환하는 메서드 테스트
    @DisplayName("getAuthentication(): 토큰 기반으로 인증 정보를 가져올 수 있다.")
    @Test
    void getAuthentication() {
        // [given] jjwt라이브러리 사용해 토큰 생성
        String userEmail = "user@email.com";
 
        String token = JwtFactory.builder()
                .subject(userEmail)
                .build()
                .createToken(jwtProperties);
 
        // [when] 인증 객체 반환받음
        Authentication auth = tokenProvider.getAuthentication(token);
 
        // [then] 반환받은 인증 객체의 유저 이름이 given절의 subejct 값과 같은지 확인
        assertThat(((UserDetails) auth.getPrincipal()).getUsername()).isEqualTo(userEmail);
    }
 
    // 4) getUserId() 검증 테스트 - 토큰 기반 유저 ID 가져오는 메서드 테스트
    @DisplayName("getUserId(): 토큰으로 유저ID를 가져올 수 있다.")
    @Test
    void getUserId() {
        // [given] jjwt라이브러리 사용해 토큰 생성하고, 클레임 추가
        Long userId = 1L;
 
        String token = JwtFactory.builder()
                .claims(Map.of("id", userId))
                .build()
                .createToken(jwtProperties);
 
        // [when] 유저ID 반환받음
        Long userIdByToken = tokenProvider.getUserId(token);
 
        // [then] 반환받은 유저 ID가 given절의 유저ID 값과 같은지 확인
        assertThat(userIdByToken).isEqualTo(userId);
    }
}
 
cs

 

 

[결과]

 

 

3. 리프레시 토큰 도메인 구현하기

1) domain/RefreshToken.java

@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@Entity
public class RefreshToken {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY) // 일련번호. 기본키
    @Column(name = "id", updatable = false)
    private Long id;

    @Column(name = "user_id", nullable = false, unique = true)
    private Long userId;

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

    public RefreshToken(Long userId, String refreshToken) {
        this.userId = userId;
        this.refreshToken = refreshToken;
    }

    public RefreshToken update(String newRefreshToken) {
        this.refreshToken = newRefreshToken;
        return this;
    }
}

 

 

2) repository/RefreshTokenRepository.java

public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long> {
    Optional<RefreshToken> findByUserId(Long userId);
    Optional<RefreshToken> findByRefreshToken(String refreshToken);
}

 

 

4. 토큰 필터 구현하기

필터

: 각종 요청 처리 위한 로직으로, 요청이 전달되기 전후에 URL 패턴에 맞는 모든 요청을 처리하는 기능을 제공한다. 요청이 오면 헤더값을 비교해 토큰이 있는지 확인하고, 유효 토큰이라면 시큐리티 콘텍스트 홀더에 인증 정보를 저장한다.

 

 

시큐리티 컨텍스트

 - 인증 객체가 저장되는 보관소

 - 스레드 로컬(스레드마다 할당되는 공간)에 저장되므로 코드의 아무 곳에서나 참조 가능

 - 다른 스레드와 공유하지 않으므로 독립적으로 사용 가능

⇒ 시큐리티 컨텍스트 객체를 저장하는 객체 : 시큐리티 컨텍스트 홀더 !

 

- config/TokenAuthenticationFilter.java

: 액세스 토큰값이 담긴 Authorization 헤더값을 가져온 뒤, 액세스 토큰이 유효하다면 인증 정보 설정

[전체 코드]

@RequiredArgsConstructor
public class TokenAuthenticationFilter extends OncePerRequestFilter {

    private final TokenProvider tokenProvider;
    private final static String HEADER_AUTHORIZATION = "Authorization";
    private final static String TOKEN_PREFIX = "Bearer ";

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {

        // 요청 헤더의 Authorization 키의 값 조회
        String authorizationHeader = request.getHeader(HEADER_AUTHORIZATION);

        // 가져온 값에서 접두사 제거
        String token = getAccessToken(authorizationHeader);

        // 가져온 토큰이 유효한지 확인하고, 유효한 때는 인증 정보 설정
        if(tokenProvider.validToken(token)) {
            Authentication authentication = tokenProvider.getAuthentication(token);
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }

        filterChain.doFilter(request, response);
    }

    private String getAccessToken(String authorizationHeader) {
        if(authorizationHeader != null && authorizationHeader.startsWith(TOKEN_PREFIX)) {
            return authorizationHeader.substring(TOKEN_PREFIX.length());
        }
        return null;
    }
}

➔ 요청 헤더에서 키가 'Authorization'인 필드의 값을 가져온 후, 토큰의 접두사 Bearer을 제외한 값을 얻는다. 값이 null이거나 Bearer로 시작하지 않으면 null을 반환한다.

// 접두사 제거 메서드
private String getAccessToken(String authorizationHeader) {
    if(authorizationHeader != null && authorizationHeader.startsWith(TOKEN_PREFIX)) {
        return authorizationHeader.substring(TOKEN_PREFIX.length());
    }
    return null;
}

 

 

가져온 토큰이 유효한지 확인하고, 유효하다면 인증 정보를 관리하는 시큐리티 컨텍스트에 인증 정보를 설정한다.

// 가져온 토큰이 유효한지 확인하고, 유효한 때는 인증 정보 설정
if(tokenProvider.validToken(token)) {
    Authentication authentication = tokenProvider.getAuthentication(token);
    SecurityContextHolder.getContext().setAuthentication(authentication);
}

 

 

 

 

📗 토큰API 구현하기

: 리프레시 토큰 전달받아 검증하고, 유효한 리프레시 토큰이라면 새 액세스 토큰을 생성하는 토큰 API

 

1. 토큰 서비스 추가하기

1) [UserService.java] findById() 메서드 추가

// 유저 ID로 유저 검색해 전달하는 메서드
public User findById(Long userId) {
    return userRepository.findById(userId)
            .orElseThrow(() -> new IllegalArgumentException("Unexpected user"));
}

 

2) [service/RefreshTokenService.java]

@RequiredArgsConstructor
@Service
public class RefreshTokenService {

    private final RefreshTokenRepository refreshTokenRepository;

    // 전달받은 리프레시 토큰으로 리프레시 토큰 객체 검색해 전달하는 메서드
    public RefreshToken findByRefreshToken(String refreshToken) {
        return refreshTokenRepository.findByRefreshToken(refreshToken)
                .orElseThrow(() -> new IllegalArgumentException("Unexpected token"));
    }
}

 

3) 토큰 서비스 클래스 생성 (service/TokenService.java)

@RequiredArgsConstructor
@Service
public class TokenService {

    private final TokenProvider tokenProvider;
    private final RefreshTokenService refreshTokenService;
    private final UserService userService;

    // 전달받은 리프레시 토큰으로 유효성 검사하고, 유효한 토큰일 때 리프레시 토큰으로 사용자 ID 찾는 메서드
    public String createNewAccessToken(String refreshToken) {
        // 토큰 유효성 검사에 실패하면 예외 발생
        if(!tokenProvider.validToken(refreshToken)) {
            throw new IllegalArgumentException("Unexpected Token");
        }

        // 사용자 ID로 사용자 찾은 후, 토큰 제공자의 generateToken() 메서드 호출해 새 액세스 토큰 생성
        Long userId = refreshTokenService.findByRefreshToken(refreshToken).getUserId();
        User user = userService.findById(userId);

        return tokenProvider.generateToken(user, Duration.ofHours(2));
    }
}

 

 

 

2. 컨트롤러 추가하기

: 실제로 토큰을 발급받는 API 생성하기

 

 

1) dto 디렉터리에 CreateAccessTokenRequest.java, CreateAccessTokenResponse.java 파일 생성

: 토큰 생성 요청 및 응답 당담

 

→ CreateAccessTokenRequest.java

@Getter
@Setter
public class CreateAccessTokenRequest {
    private String refreshToken;
}

 

CreateAccessTokenResponse.java

@AllArgsConstructor
@Getter
public class CreateAccessTokenResponse {
    private String accessToken;
}

 

 

2) [controller/TokenApiController.java]

: /api/token POST 요청이 오면 토큰 서비스에서 리프레시 토큰을 기반으로 새 액세스 토큰을 생성한다.

@RequiredArgsConstructor
@RestController
public class TokenApiController {

    private final TokenService tokenService;

    // 토큰 서비스에서 리프레시 토큰을 기반으로 새 액세스 토큰을 만드는 메서드
    @PostMapping("/api/token")
    public ResponseEntity<CreateAccessTokenResponse> createNewAccessToken(@RequestBody CreateAccessTokenRequest request) {
        String newAccessToken = tokenService.createNewAccessToken(request.getRefreshToken());

        return ResponseEntity.status(HttpStatus.CREATED)
                .body(new CreateAccessTokenResponse(newAccessToken));
    }
}

 

 

3) 테스트 코드 작성해 동작 확인하기 (test/.../controller/TokenApiControllerTest.java)

: createNewAccessToken()에 대한 테스트 코드

TokenApiController.java 코드 확인하기
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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
@SpringBootTest
@AutoConfigureMockMvc
public class TokenApiControllerTest {
 
    @Autowired
    protected MockMvc mockMvc;
 
    @Autowired
    protected ObjectMapper objectMapper;
 
    @Autowired
    private WebApplicationContext context;
 
    @Autowired
    JwtProperties jwtProperties;
 
    @Autowired
    UserRepository userRepository;
 
    @Autowired
    RefreshTokenRepository refreshTokenRepository;
 
    @BeforeEach
    public void mockMvcSetUp() {
        this.mockMvc = MockMvcBuilders.webAppContextSetup(context)
                .build();
 
        userRepository.deleteAll();
    }
 
    @DisplayName("createNewAccessToken: 새 액세스 토큰 발급")
    @Test
    public void createNewAccesToken() throws Exception {
        // [given]
        final String url = "/api/token";
 
        // 테스트 유저 생성하기
        User testUser = userRepository.save(User.builder()
                .email("user@gmail.com")
                .password("test")
                .build());
 
        // jjwt 라이브러리 사용해 리프레시 토큰 생성하기
        String refreshToken = JwtFactory.builder()
                .claims(Map.of("id", testUser.getId()))
                .build()
                .createToken(jwtProperties);
 
        // 만든 리프레시 토큰 DB에 저장하기
        refreshTokenRepository.save(new RefreshToken(testUser.getId(), refreshToken));
 
        // 토큰 생성 API의 요청 본문에 리프레시 토큰 포함해 요청 객체 생성하기
        CreateAccessTokenRequest request = new CreateAccessTokenRequest();
        request.setRefreshToken(refreshToken);
 
        final String requestBody = objectMapper.writeValueAsString(request);
 
 
        // [when]
        ResultActions resultActions = mockMvc.perform(post(url)
                .contentType(MediaType.APPLICATION_JSON_VALUE)    // 토큰 추가 API에 JSON 타입으로 요청 보내고,
               .content(requestBody));                           // given 절에서 미리 만들어둔 객체를 요청 본문으로 함께 전송
 
        // [then]
        resultActions
                .andExpect(status().isCreated())                        // 응답 코드가 201 Created인지 확인하고, 
                .andExpect(jsonPath("$.accessToken").isNotEmpty());     // 응답으로 온 액세스 토큰이 비어있지 않은지 확인
    }
}
 
cs

 

 

[결과]

 

 

 

(해당 글 내용은 📗 스프링 부트 3 백엔드 개발자 되기 - 자바 편을 읽고 정리한 내용입니다.)

 

 

반응형