📗 토큰 기반 인증
⭐ 토큰 기반 인증
인증 정보를 서버/세션에 저장하지 않고, 클라이언트 측에서 발급받은 토큰으로 인증하는 방식
* 토큰이란? 서버에서 클라이언트를 구분하기 위한 유일한 값
토큰을 전달하고 인증받는 과정
- 서버가 토큰을 생성해 클라이언트에게 제공하면, 클라이언트는 이 토큰을 갖고 있다가 요청할 때마다 요청 내용과 함께 토큰을 전송한다. (= 토큰을 갖는 주체 : 클라이언트)
토큰 기반 인증 특징
① 무상태성
사용자의 인증 정보가 담긴 토큰이 서버가 아닌 클라이언트에 있어 서버에 저장할 필요가 없음을 의미.
- 상태 관리 : 클라이언트에서 사용자의 인증 상태를 유지하면서 이후 요청을 처리하는 것 → 서버는 무상태로 효율적인 검증 가능
② 확장성
토큰을 가지는 주체가 클라이언트이므로 갖고 있는 하나의 토큰으로 여러 서버에 동시에 요청 전송 가능.
- 토큰 기반 인증을 사용하는 다른 시스템에 접근할 수도 있고, 이를 활용해 다른 서비스에 권한을 공유할 수도 있음.
③ 무결성
토큰 발급 이후, 토큰 정보 변경 불가!
vs 세션 인증
⭐ 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 백엔드 개발자 되기 - 자바 편을 읽고 정리한 내용입니다.)
'📚 관련 독서 > 스프링 부트 3 백엔드 개발자 되기 - 자바 편' 카테고리의 다른 글
[SpringBoot] 11장 AWS에 프로젝트 배포하기 (0) | 2024.02.22 |
---|---|
[SpringBoot] 10장 OAuth2 ver. 로그인/로그아웃 구현 (0) | 2024.02.21 |
[SpringBoot] 08장 스프링 시큐리티 ver. 로그인/로그아웃, 회원 가입 구현 (0) | 2024.02.13 |
[SpringBoot] 07장 블로그 화면 구성하기 (타임리프) (0) | 2024.02.08 |
[SpringBoot] 06장 블로그 기획하고 API 만들기 (0) | 2023.08.17 |