
'스프링 부트3 백엔드 개발자 되기' 책을 참고하며 작성 중 입니다.
대표적인 사용자 인증 확인 방법
서버 기반 인증
Spring Security : 세션 기반 인증
토큰 기반 인증
Token : 서버에서 클라이언트를 구분하기 위한 유일한 값
(서버) 토큰 제작 -> 클라이언트에게 제공
-> (클라이언트) 토큰을 포함한 요청 -> (서버) 토큰으로 사용자의 유효성 확인
- 클라이언트가 아이디와 비밀번호를 서버에게 전달하면서 인증 요청
- 서버는 아이디와 비밀번호를 확인해 유효한 사용자인지 검증, 유효한 사용자인 경우 토큰 생성하여 응답
- 클라이언트는 서버에서 준 토큰 저장
- 인증이 필요한 API를 사용할 때 헤더에 토큰을 담아서 요청
- 서버는 헤더에 있는 토큰이 유효한지 검증
- 토큰이 유효하다면 클라이언트가 요청한 내용 처리
만약 유효하지 않다면? RefreshToken 처리 (아래 설명)
사용자의 인증 정보가 있는 토큰이 클라이언트에 있으므로
서버가 클라이언트의 인증 정보를 저장하거나 유지하지 않아도 된다.
완전한 무상태로 효율적인 검증
서버를 확장할 때 상태 관리를 신경 쓸 필요가 없음
세션 인증 기반 : 각각 API에서 인증을 해야 하는 것과 달리
토큰 기반 인증 : 하나의 토큰으로 모든 요청 가능
카카오, 구글 로그인 같이 토큰 기반 인증을 사용하는 다른 시스템에 접근해 로그인 방식을 확장할 수도 있고, 이를 활용해 다른 서비스의 권한 공유 가능
토큰 방식 : HMAC (Hash-based Message AuthentiCation) 기법
토큰을 발급하면 토큰 정보 변경 불가 (무결성)
한 글자라도 변경될 시 유효하지 않은 토큰
JWT를 이용해 인증을 하려면 HTTP 요청 헤더 중
Authorization : Bearer + JWT 토큰 값
aaaa.bbbb.cccc
헤더 .내용 . 서명
토큰의 타입 , 해싱 알고리즘을 지정하는 정보 포함
"typ" : "JWT",
"alg" : "HS256"
JWT 토큰, HS256 해싱 알고리즘을 사용
토큰과 관련된 내용
Claim(클레임) : 내용의 한 덩어리
등록된 클레임
이름 설명 iss 토큰 발급자(issuer) sub 토큰 제목(subject) aud 토큰 대상자 (audience) exp 토큰의 만료 시간(expiration), NumericDate 형식, 항상 현재 시간 이후로 설정 nbf nbf = Not Before, NumericDate 형식, 이 날짜가 지나기 전까지는 토큰이 처리되지 않음. iat 토큰이 발급된 시간으로 iat은 issued at을 의미 jti JWT의 고유 식별자로서 주로 일회용 토큰에서 사용
공개 클레임
공개되어도 상관없는 클레임
보통 URI로 지음
비공개 클레임
공개되면 안되는 클레임
클라이언트와 서버 간의 통신에 사용
"iss" : "spring@gmail.com",
"iat" : 1622370878,
"exp" : 1622372678, //등록된 클레임
"https://spring.com/jwt_claims/is_admin" : true, //공개 클레임
"email" : "spring@gmail.com", //비공개 클레임
"hello" : "안녕하세요" //비공개 클레임
해당 토큰이 조작되었거나 변경되지 않았음을 확인하는 용도
헤더의 인코딩 값 + 내용의 인코딩 값 + 비밀키 = 해시값
토큰이 이미 발급되면 그 자체로 인증 수단이 되어 토큰이 탈취되어도 알 수 없음.
사용자의 인증 용도가 아닌 액세스 토큰(AccessToken)이 만료되었을 때 새로운 액세스 토큰 발급하기 위해 사용
보통 액세스 토큰의 유효 기간을 짧게,
리프레시 토큰의 유효 기간을 길게 하여
액세스 토큰이 탈취되어도 최대한 안전하게 한다.

- 클라이언트가 서버에게 인증 요청
- 서버는 전달된 정보를 바탕으로 인증 정보가 유효한지 확인한 뒤, 액세스 토큰과 리프레시 토큰을 만들어 클라이언트에게 전달
클라이언트는 전달받은 토큰 저장- 서버에서 생성한 리프레시 토큰은 DB에도 저장
- 인증이 필요한 API를 호출할 때 클라이언트에 저장된 액세스 토큰과 함께 API 요청
- 서버는 전달받은 액세스 토큰이 유효한지 검사한 뒤에 유효하면 응답 처리
- 시간이 지나 만료된 액세스 토큰과 함께 API 요청
- 만료된 토큰이기에 토큰이 만료되었다는 에러 전달
- 클라이언트는 이 응답을 받고 저장해둔 리프레시 토큰과 함께 새로운 액세스 토큰을 발급하는 요청 전송
- 전달받은 리프레시 토큰이 유효한지,
DB의 리프레시 토큰과 비교하여 같은지 확인- 유효한 리프레시 토큰이면 새로운 액세스 토큰을 생성하여 응답
그 이후는 다시 4로 돌아가서 진행
JWT를 사용하기 위한 라이브러리 추가
XML 문서와 자바 객체 간 매핑을 자동화하는 jax-api
dependencies {
testAnnotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.projectlombok:lombok'
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
implementation 'io.jsonwebtoken:jjwt-impl:0.11.5'
implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5'
//jwt를 사용하기 위해서는 위 3개의 의존성을 모두 추가 (버전 업데이트)
implementation 'javax.xml.bind:jaxb-api:2.3.1'
}
jwt:
issuer: minkyeong0244@gmail.com
secretKey: c29tZVNlY3VyZUtleVRleHRUZXh0VGV4dFRleHRUZXh0VGV4dFRleHRUZXh0
#HMAC-SHA 알고리즘의 보안 요구 사항 준수
study-springboot로 설정할 시 '-'를 decode할 수 없다는 오류가 뜬다.
HMAC-SHA 알고리즘의 보안 요구 사항을 준수하여 HS256으로 encode 된 값을 사용
해당 값들을 변수로 접근하는 데 사용
@Setter
@Getter
@Component
@ConfigurationProperties("jwt") //해당 클래스에 프로피티값을 가져와서 사용 가능
public class JwtProperties {
private String issuer;
private String secretKey;
}
@ConfigurationProperties("jwt") : application.yml에서 설정한 jwt 매개변수 값들을 가져와서 사용하는 애너테이션
issuer 필드에는 jwt.issuer 값이, secretKey에는 jwt.secretKey 값이 매핑
토큰 생성
올바른 토큰인지 유효성 검사
토큰에서 필요한 정보를 반환
import java.security.Key
@Service
public class TokenProvider {
private final JwtProperties jwtProperties;
private final Key key;
public TokenProvider(JwtProperties jwtProperties){
this.jwtProperties = jwtProperties;
byte[] keyBytes = Decoders.BASE64.decode(jwtProperties.getSecretKey());
this.key = Keys.hmacShaKeyFor(keyBytes);
}
public String generateToken(User user, Duration expiredAt){
Date now = new Date();
return makeToken(new Date(now.getTime() + expiredAt.toMillis()), user);
}
Java 내에서 지원하는 Key 객체를 사용
Decoders.BASE64.decode() : Base64 인코딩된 문자열을 디코딩하여 바이트 배열로 반환
Keys.hmacShaKeyFor() : HMAC SHA 알고리즘을 사용하는 데 필요한 키 객체 생성
토큰 생성 메서드 makeToken()
//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 : yml 파일 설정 값
.setIssuedAt(now) //내용 iat : 현재 시간
.setExpiration(expiry) //내용 exp : 현재 시간 + 유효 시간
.setSubject(user.getEmail()) //내용 sub : 유저의 email
.claim("id", user.getId()) //클레임 id : 유저 ID
.signWith(key, SignatureAlgorithm.HS256) //서명 : 비밀값과 함께 해시값을 HS256 방식으로 암호화
.compact();
}
인자 : 만료 시간(expiry), 유저 정보(user)
set 계열의 메서드를 통해 여러 값을 지정
헤더 : typ(타입)
내용 : iss(발급자), iat(발급 일시), exp(만료 일시), sub(토큰 제목)
클레임 : 유저 ID
비밀값과 함께 HS256 방식으로 암호화
토큰이 유효한지 검증하는 메서드 validToken()
//JWT 토큰 유효성 검증 메서드
public boolean validToken(String token){
try {
Jwts.parserBuilder()
.setSigningKey(key) //비밀값으로 복호화
.build()
.parseClaimsJws(token);
return true;
} catch (Exception e){
return false; //복호화 과정에서 에러가 나면 유효하지 않은 토큰
}
}
key 값으로 다시 복호화를 진행
에러가 발생하면 유효하지 않은 토큰이므로 에러 -> false
토큰을 받아 인증 정보 (Authentication)을 반환하는 메서드 getAuthentication()
private Claims getClaims(String token){
return Jwts.parserBuilder() //클레임 조회
.setSigningKey(key)
.build()
.parseClaimsJws(token)
.getBody();
}
//토큰 기반으로 인증 정보를 가져오는 메서드
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
);
}
key 값으로 복호화 진행 후 Claim을 가져오는 getClaims()을 사용
클레임 정보를 반환받아 사용자 이메일이 들어 있는 토큰 제목 sub와
토큰 기반으로 인증 정보 생성
!! 여기서 UsernamePasswordAuthenticationToken객체에 들어가는 User 객체는 spring security에서 제공하는 User 객체
토큰 기반으로 사용자 ID를 가져오는 메서드 getUserId()
//토큰 기반으로 유저 ID를 가져오는 메서드
public Long getUserId(String token){
Claims claims = getClaims(token);
return claims.get("id", Long.class);
}
}
key 값으로 복호화 진행 후 Claim을 가져오는 getClaims()을 사용
클레임 정보를 반환받고 클레임에서 id 키로 저장된 값을 가져와 반환
JWT 토큰 서비스를 테스트하는 데 사용할 모킹(moking)용 객체
모킹 : 테스트를 실행할 때 객체를 대신하는 가짜 객체
@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){
byte[] keyBytes = Decoders.BASE64.decode(jwtProperties.getSecretKey());
Key key = Keys.hmacShaKeyFor(keyBytes);
return Jwts.builder()
.setSubject(subject)
.setHeaderParam(Header.TYPE, Header.JWT_TYPE)
.setIssuer(jwtProperties.getIssuer())
.setIssuedAt(issuedAt)
.setExpiration(expiration)
.addClaims(claims)
.signWith(key)
.compact();
}
}
@SpringBootTest
public class TokenProviderTest {
@Autowired
private TokenProvider tokenProvider;
@Autowired
private UserRepository userRepository;
@Autowired
private JwtProperties jwtProperties;
private Key key;
@BeforeEach
void setUp() {
byte[] keyBytes = Decoders.BASE64.decode(jwtProperties.getSecretKey());
this.key = Keys.hmacShaKeyFor(keyBytes);
}
/*
Given : 토큰에 유저 정보를 추가 하기 위한 테스트 유저 제작
When : 토큰 제공자의 generateToken() 메서드를 호출해 토큰 제작
Then : jjwt 라이브러리를 사용해 토큰 복호화, 토큰을 만들 때 claim으로 넣어둔 id 값이
given 절에서 만든 유저의 id와 일치하는 지
*/
@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
Long userId = Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token)
.getBody()
.get("id", Long.class);
assertThat(userId).isEqualTo(testUser.getId());
}
/*
Given : jjwt 라이브러리를 사용해 토큰 생성, 만료된 토큰으로 생성
When : validToken() 메서드를 호출해 유효한 토큰인지 검증한 뒤 결과값 반환
Then : 반환값이 false 인 것 확인
*/
@DisplayName("validToken() : 만료된 토큰인 때에 유효성 검증에 실패한다.")
@Test
void validToken_invalidToken() {
//given
String token = JwtFactory.builder()
.expiration(new Date(new Date().getTime() - Duration.ofDays(7).toMillis()))
.build()
.createToken(jwtProperties);
//when
boolean result = tokenProvider.validToken(token);
//then
assertThat(result).isFalse();
}
/*
Given : jjwt 라이브러리를 사용해 토큰 생성, 만료일은 현재 부터 14일 뒤로 만료되지 않은 토큰
When : validToken() 메서드를 호출해 유효한 토큰인지 검증한 뒤 결과값 반환
Then : 반환값이 true 인 것 확인
*/
@DisplayName("validToken() : 유효한 토큰인 때에 유효성 검증에 성공한다.")
@Test
void validToken_validToken() {
//given
String token = JwtFactory.withDefaultValues().createToken(jwtProperties);
//when
boolean result = tokenProvider.validToken(token);
//then
assertThat(result).isTrue();
}
/*
Given : jjwt 라이브러리를 사용해 토큰 생성, 토큰 제목(subject) : 유저 이메일
When : getAuthentication() 메서드를 호출해 인증 객체 반환
Then : 반환받은 인증 객체의 유저 이름을 가져와 given절에서 설정한 subject 값(userEmail)과 같은 지 확인
*/
@DisplayName("getAuthentication() : 토큰 기반으로 인증 정보를 가져올 수 있다.")
@Test
void getAuthentication(){
//given
String userEmail = "test@gmail.com";
String token = JwtFactory.builder()
.subject(userEmail)
.build()
.createToken(jwtProperties);
//when
Authentication authentication = tokenProvider.getAuthentication(token);
//then
assertThat(((UserDetails) authentication.getPrincipal()).getUsername()).isEqualTo(userEmail);
}
/*
Given : jjwt 라이브러리를 사용해 토큰 생성, 토큰 claim에 "id" 키를 추가 ("id" : userId")
When : getUserId() 메서드를 호출해 유저 Id 반환
Then : 반환받은 유저 Id와 given절에서 설정한 userId이 같은 지 확인
*/
@DisplayName("getUserId() : 토큰으로 유저 ID를 가져올 수 있다.")
@Test
void getUserId(){
//given
Long userId = 1L;
String token = JwtFactory.builder()
.claims(Map.of("id", userId))
.build()
.createToken(jwtProperties);
//when
Long userIdByToken = tokenProvider.getUserId(token);
//then
assertThat(userIdByToken).isEqualTo(userId);
}
}
| 칼럼명 | 자료형 | null 허용 | 키 | 설명 |
|---|---|---|---|---|
| id | BIGINT | N | 기본키 | 일련번호, 기본키 |
| user_id | BIGINT | N | 유저 ID | |
| refresh_token | VARCHAR(255) | N | 토큰값 |
@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;
}
}
public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long> {
Optional<RefreshToken> findByUserId(Long userId);
Optional<RefreshToken> findByRefreshToken(String refreshToken);
}
실제로 각종 요청을 처리하기 위한 로직으로
전달되기 전후에 URL 패턴에 맞는 모든 요청을 처리하는 기능 제공
요청이 오면 헤더값을 비교해서 토큰이 있는지 확인
유효 토큰이라면?
Security Context Holder (시큐리티 콘텍스트 홀더)에 인증 정보 저장

인증 객체가 저장되는 보관소
이런 Security Context 객체를 저장하는 객체가 Security Context Holder
액세스 토큰값이 담긴 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'을 제외한 값 = 토큰 값
토큰이 유효하다면 Security Context에 인증 정보 설정
코드가 실행되고 인증 정보가 설정되면
Context Holder에서 getAuthentication() 메서드를 통해 유저 객체 반환 가능
유저 객체에는 유저 이름 (username)과 권한 목록(authorities) 포함
유저 ID로 유저 검색
@RequiredArgsConstructor
@Service
public class UserService {
//..생략
public User findById(Long userId){
return userRepository.findById(userId)
.orElseThrow(() -> new IllegalArgumentException("Unexpected user"));
}
}
RefreshToken으로 RefreshToken 객체 검색
@RequiredArgsConstructor
@Service
public class RefreshTokenService {
private final RefreshTokenRepository refreshTokenRepository;
public RefreshToken findByRefreshToken(String refreshToken){
return refreshTokenRepository.findByRefreshToken(refreshToken)
.orElseThrow(() -> new IllegalArgumentException("Unexpected token"));
}
}
RefreshToken으로 토큰 유효성 검사를 진행
유효한 토큰 이면? RefreshToken으로 사용자 ID 찾기
사용자 ID로 사용자를 찾아 새로운 액세스 토큰 생성
@Service
@RequiredArgsConstructor
public class TokenService {
private final TokenProvider tokenProvider;
private final RefreshTokenService refreshTokenService;
private final UserService userService;
public String createNewAccessToken(String refreshToken){
if(!tokenProvider.validToken(refreshToken)){
throw new IllegalArgumentException("Unexpected token");
}
Long userId = refreshTokenService.findByRefreshToken(refreshToken).getUserId();
User user = userService.findById(userId);
return tokenProvider.generateToken(user, Duration.ofHours(2));
}
}
@Getter
@Setter
public class CreateAccessTokenRequest {
private String refreshToken;
}
@Getter
@AllArgsConstructor
public class CreateAccessTokenResponse {
private String accessToken;
}
@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));
}
}
@SpringBootTest
@AutoConfigureMockMvc
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();
}
/*
Given : 테스트 유저 생성, jjwt 라이브러리를 사용하여 리프레시 토큰을 만들어 데이터베이스에 저장
토큰 생성 API의 요청 본문에 리프레시 토큰을 포함하여 요청 객체를 생성
When : 토큰 추가 API에 요청을 보냄, 요청 타입 : JSON, given 절에서 미리 만들어둔 객체를 요청 본문으로 함께 보냄
Then : 응답 코드가 201 Created, 엑세스 토큰이 비어있지 않은 지 확인
*/
@DisplayName("createNewAccessToken : 새로운 액세스 토큰을 발급한다.")
@Test
public void createNewAccessToken() throws Exception {
//given
final String url = "/api/token";
User testUser = userRepository.save(User.builder()
.email("user@gmail.com")
.password("test")
.build());
String refreshToken = JwtFactory.builder()
.claims(Map.of("id", testUser.getId()))
.build()
.createToken(jwtProperties);
refreshTokenRepository.save(new RefreshToken(testUser.getId(), refreshToken));
CreateAccessTokenRequest request = new CreateAccessTokenRequest();
request.setRefreshToken(refreshToken);
final String requestBody = objectMapper.writeValueAsString(request);
//when
ResultActions result = mockMvc.perform(post(url)
.contentType(MediaType.APPLICATION_JSON_VALUE)
.content(requestBody));
//then
result.andExpect(status().isCreated())
.andExpect(jsonPath("$.accessToken").isNotEmpty());
}
}
토큰 기반 인증의 특징과 토큰의 일종인 JWT를 알아보고 토큰 제공자를 만들었습니다.
토큰 기반 인증, JWT, RefreshToken, 필터, Security Context
JWT 토큰에 대해서 잘 정리 해주셔서 감사합니다.
저도 공부중인데 유익한 글을 보고 많이 배워갑니다.