사용자가 서버에 접근할 때 사용자가 인증된 사용자인지 확인하는 방법 (= 사용자 인증 확인 방법)
스프링부트 시큐리티에서 기본적으로 ‘세션 기반 인증’을 제공해줌. 8장에서는 이를 활용해 사용자의 정보를 담은 세션 생성/저장하여 인증 진행함
토큰 기반 인증:
토큰을 전달하고 인증받는 과정
요청과 응답에 토큰을 함께 보냄.

토큰 기반 인증의 특징
대표적으로 ‘무상태성, 확장성, 무결성’이라는 특징이 존재
발급받은 JWT를 이용해 인증하기 위해서는 HTTP 요청 헤더 중에 Authorization 키값에 Bearer + JWT 토큰값을 넣어 보내야함
JWT의 구조: .을 기준으로 헤더 + 내용 + 서명으로 구성
등록된 클레임: 토큰에 대한 정보를 담는 데에 사용
| 이름 | 설명 |
|---|---|
| iss | 토큰 발급자 (issuer) |
| sub | 토큰 제목 (subject) |
| aud | 토큰 대상자 (audience) |
| exp | 토큰의 만료시간(expiration), 시간은 NumericDate 형식으로하며, 현재 시간 이후로 설정 |
| nbf | 토큰의 활성 날짜와 비슷한 개념으로 nbf는 Not Before을 의미. NumericDate 형식으로 날짜를 지정하며, 해당 날짜가 지나기 전까지는 토큰이 처리되지 않음 |
| iat | 토큰이 발급된 시간으로 iat는 issued at을 의미함 |
| jti | JWT의 고유 식별자로서 주로 일회용 토큰에 사용함 |
공개 클레임: 공개되어도 상관없는 클레임을 의미. 충돌을 방지할 수 있는 이름을 가져야하며, 보통 클레임 이름을 URI로 지음
비공개 클레임: 공개되면 안되는 클레임을 의미하며 클라이언트와 서버 간의 통신에 사용됨
{
"iss": "ajufresh@gmail.com", //등록된 클레임
"iat": 1622370878, //등록된 클레임
"exp": 1622372678, //등록된 클레임
"https://sumyoung.com/jwt_claims/is_admin":true, //공개 클레임
"email": "test@example.com", //비공개 클레임
"hello": "안녕하세요" //비공개 클레임
}
⇒ iss, iat, exp는 JWT 자체에서 등록된 클레임. URI로 네이밍된 공개 클레임. 등록된 클레임도, 공개 클레임도 아닌 email과 hello는 비공개 클레임
토큰 유효기간
토큰을 주고받는 환경이 보안에 취약해 토큰 자체가 노출되면 → 토큰은 발급된 이후 그 자체로 인증 수단이 되기에 서버는 토큰과 함께 들어온 요청이 토큰을 탈취한 사람의 요청인지 확인할 수 없음
리프레시 토큰: 액세스 토큰과 별개의 토큰으로 사용자를 인증하기 위한 용도가 아닌 액세스 토큰이 만료되었을 때, 새로운 액세스 토큰을 발급하기 위해 사용.
액세스 토큰의 유효기간을 짧게 설정하고, 리프레시 토큰의 유효 기간을 길게 설정하면 공격자가 액세스 토큰을 탈취해도 몇 분 뒤에는 사용할 수 없는 토큰이 되기에 안전

1- 클라이언트가 서버에게 인증 요청
2- 서버는 클라이언트에서 전달한 정보를 바탕으로 인증 정보가 유효한지 확인한 뒤, 액세스 토큰과 리프레시 토큰을 만들어 클라이언트에게 전달 & 클라이언트는 전달받은 토큰을 저장
3-서버에서 생성한 리프레시 토큰은 DB에 저장
4- 인증을 필요로 하는 API를 호출할 때 클라이언트에 저장된 액세스 토큰과 함께 API 요청
5- 서버는 전달받은 액세스 토큰이 유효한지 검사한 뒤에 유효하다면, 클라이언트에서 요청한 내용을 처리
6- 시간이 지나고 액세스 토큰이 만료된 뒤에 클라이언트에서 원하는 정보를 얻기 위해 서버에게 API 요청을 보냄
7- 서버는 액세스 토큰이 유효한지 검사 ⇒ 만료된 토큰이면 유효하지 않기에, 토큰이 만료되었다는 에러 전달
8- 클라이언트는 응답을 받은 뒤, 리프레시 토큰과 함께 새로운 액세스 토큰 발급 요청을 전송
9- 서버는 전달받은 리프레시 토큰이 유효한지, DB에 저장한 리프레시 토큰과 같은지 확인
10- 유효한 리프레시 토큰이면 새로운 액세스 토큰을 생성한 뒤 응답
목표: 실제 JWT 생성, 검증하는 서비스 구현하기
과정: 의존성과 토큰 제공자를 추가 → 리프레시 토큰 도메인과 토큰 필터를 구현
(10장에서 구현한 OAuth 서비스에서 이 장의 클래스들 사용할 예정)
dependencies {
...
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 객체 간 매핑 자동화
}

jwt를 사용해서 JWT를 생성하고 유효한 토큰인지 검증하는 역할을 하는 클래스 추가
spring:
jpa:
#전송 쿼리 확인
show-sql: true
properties:
hibernate:
format_sql: true
#테이블 생성 후에 data.sql 실행
defer-datasource-initialization: true
datasource:
url: jdbc:h2:mem:testdb
username: sa
h2:
console:
enabled: true
jwt:
issuer: test@example.com
secret_key: study-springbootconfig/jwt 패키지에 JwtProperties.java 파일을 만들어 코드 작성
⇒ issuer 필드에는 application.yml 파일의 jwt.issuer 값, secretKey에는 jwt.secret_key 값이 매핑됨
@Setter
@Getter
@Component
@ConfigurationProperties("jwt") //자바 클래스에 프로퍼티값을 가져와서 사용하는 애너테이션
public class JwtProperties {
private String issuer;
private String secretKey;
}
계속해서 토큰을 생성하고, 올바른 토큰인지 유효성을 검사하고, 토큰에서 필요한 정보를 가져오는 클래스 작성하기
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
//내용 iss: test@example.com (propertise 파일에서 설정한 값)
.setIssuer(jwtProperties.getIssuer())
.setIssuedAt(now) //내용 iat: 현재 시간
.setExpiration(expiry) //내용 exp: expiry 멤버 변수값
.setSubject(user.getEmail()) //내용 sub: 유저의 이메일
.claim("id", user.getId()) //클레임id: 유저 ID
//서명: 비밀값과 함께 해시값을 HS526 방식으로 암호화
.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();
}
}
1: makeToken(): 토큰을 생성하는 메소드. 인자는 만료시간, 유저 정보를 받음.
2: validToken(): 토큰이 유효한지 검증하는 메소드. 프로퍼티즈 파일에 선언한 비밀값과 함께 토큰 복호화 진행. 만약 복호화 과정에서 에러가 발생하면 유효하지 않은 토큰이므로 false를 반환, 아무 에러도 발생하지 않으면 true 반환.
3: getAuthentication(): 토큰을 받아 인증 정보를 담은 객체 Authentication을 반환하는 메소드.
getClaims() 메소드를 호출해 클레임 정보를 반환받아 사용자 이메일이 들어있는 토큰 제목 sub와 토큰 기반으로 인증 정보 생성4: getUserId(): 토큰 기반으로 사용자 ID를 가져오는 메소드. 프로퍼티즈 파일에 저장한 비밀값으로 토큰을 복호화한 다음 클레임을 가져오는 private 메소드인 getClaims()를 호출해서 클레임 정보를 반환받고 클레임에서 id 키로 저장된 값을 가져와 반환
테스트 코드 작성하기
//JwtFactory.java 파일
@Getter
public class JwtFactory {
private String subject = "test@example.com";
private Date issuedAt = new Date();
private Date expiration = new Date(new Date().getTime() + Duration.ofDays(14).toMillis());
private Map<String, Object> claims = 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();
}
}
TokenProvider 클래스를 테스트하는 클래스 만들기
@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@example.com")
.password("test")
.build());
//when
String token = tokenProvider.generateToken(testUser, Duration.ofDays(14));
//then
Long userId = Jwts.parser()
.setSigningKey(jwtProperties.getSecretKey())
.parseClaimsJws(token)
.getBody()
.get("id", Long.class);
assertThat(userId).isEqualTo(testUser.getId());
}
//2: validToken() 검증 테스트
@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();
}
@DisplayName("validToken(): 유효한 토큰인 때에 유효성 검증에 성공한다.")
@Test
void validToken_validToken() {
//given
String token = JwtFactory.withDefaultValues()
.createToken(jwtProperties);
//when
boolean result = tokenProvider.validToken(token);
//then
assertThat(result).isTrue();
}
//3: getAuthentication() 검증 테스트
@DisplayName("getAuthentication(): 토큰 기반으로 인증 정보를 가져올 수 있다.")
@Test
void getAuthentication() {
//given
String userEmail = "test@example.com";
String token = JwtFactory.builder()
.subject(userEmail)
.build()
.createToken(jwtProperties);
//when
Authentication authentication = tokenProvider.getAuthentication(token);
//then
assertThat(((UserDetails) authentication.getPrincipal()).getUsername()).isEqualTo(userEmail);
}
//4: getUserId() 검증 테스트
@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);
}
}
generateToken() 메소드: 토큰을 생성하는 메소드를 테스트하는 메소드validToken_invalidToken() 메소드: 토큰이 유효한 토큰인지 검증하는 메소드를 테스트하는 메소드. 검증 실패를 확인하는 메소드와 검증 실패를 확인하는 메소드로 2가지 존재getAuthentication() 메소드: 토큰을 전달받아 인증 정보를 담은 객체 Authentication을 반환하는 메소드를 테스트getUserId() 메소드: 토큰 기반으로 유저 ID를 가져오는 메소드를 테스트하는 메소드. 토큰을 프로퍼티즈 파일에 저장한 비밀값으로 복호화한 뒤 클레임을 가져오는 private 메소드인 getClaims() 를 호출해 클레임 정보를 반환받아 클레임에서 id 키로 저장된 값을 가져와 반환.
리프레시 토큰: 데이터베이스에 저장하는 정보. 따라서 엔티티와 리포지토리를 추가해야함. 만들 엔티티와 매핑되는 테이블 구조는 아래와 같음
| 컬럼명 | 자료형 | null 허용 여부 | 키 | 설명 |
|---|---|---|---|---|
| id | BIGINT | N | 기본키 | 일련번호, 기본키 |
| user_id | BIGINT | N | 유저ID | |
| refresh_token | VARCHAR(55) | 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);
}요청이 오면 헤더값을 비교해서 토큰이 있는지 확인하고 유효 토큰이라면 시큐리티 콘텍스트 홀더에 인증 정보를 저장함

시큐리티 컨텍스트: 인증 객체가 저장되는 보관소. 인증 정보가 필요할 때 언제든지 인증 객체를 꺼내서 사용할 수 있음. 스레드마다 공간을 할당 (= 스레드 로컬에 저장)되기에 코드 아무곳에서나 참조할 수 있고, 다른 스레드에서 공유하지 않기에 독립적으로 사용할 수 있음.
시큐리티 홀더: 시큐리티 컨텍스트 객체를 저장하는 객체
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;
}
}
getAuthentication() 메소드를 사용해 인증 정보를 가져오면 유저 객체를 반환.목표: 리프레시 토큰을 전달받아 토큰 제공자를 사용해 새로운 액세스 토큰을 만드는 토큰 서비스 클래스 생성하기
리프레시 토큰을 전달받아 토큰 제공자를 사용해 새로운 액세스 토큰을 만드는 토큰 서비스 클래스 생성하기
findById() 메소드를 추가로 구현하기public User findById (Long userId) {
return userRepository.findById(userId)
.orElseThrow(() -> new IllegalArgumentException
("Unexpected user"));
}findByRefreshToken() 메소드 구현하기@RequiredArgsConstructor
@Service
public class RefreshTokenService {
private final RefreshTokenRepository refreshTokenRepository;
public RefreshToken findByRefreshToken (String refreshToken) {
return refreshTokenRepository.findByRefreshToken(refreshToken)
.orElseThrow(() -> new IllegalArgumentException
("Unexpected token"));
}
}createNewAccessToken() 메소드: 전달받은 리프레시 토큰으로 토큰 유효성 검사를 진행하고, 유효한 토큰일 때 리프레시 토큰으로 사용자 ID 찾기generateToken() 메소드를 호출해 새로운 액세스 토큰을 생성@RequiredArgsConstructor
@Service
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));
}
}목표: 실제 토큰을 발급받는 API 생성하기
//CreateAccessTokenRequest.java
@Getter
@Setter
public class CreateAccessTokenRequest {
private String refreshToken;
}//CreateAccessTokenResponse.java
@AllArgsConstructor
@Getter
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 setUp() {
jwtProperties.setSecretKey("study-springboot"); // 직접 주입
jwtProperties.setIssuer("test@example.com");
}
@BeforeEach
public void mockMvcSetUp() {
this.mockMvc = MockMvcBuilders.webAppContextSetup(context)
.build();
userRepository.deleteAll();
}
@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 resultActions = mockMvc.perform(post(url)
.contentType(MediaType.APPLICATION_JSON_VALUE)
.content(requestBody));
// then
resultActions
.andExpect(status().isCreated())
.andExpect(jsonPath("$.accessToken").isNotEmpty());
}
} 
OAuth란 제3의 서비스에 계정관리를 맡기는 방식으로 네이버로 로그인하기, 구글로 로그인하기와 같은 방법
✅ OAuth 용어 정리하기
OAuth를 사용하면 인증 서버에서 발급받은 토큰을 사용해서 리소스 서버에 리소스 오너의 정보를 요청하고 응답받아 사용할 수 있음. ⇒ 클라이언트가 리소스 오너의 정보를 취득하는 방법은?
✅ 리소스 오너의 정보를 취득하는 4가지 방법

권한 요청
[권한 요청을 위한 파라미터 예:]
GET spring-authorization-server.example/authorize?
client_id=66a36bcd1&
redirect_uri=http://localhost:8080/myapp&
response_type=code&
scope=profile
client_id: 인증 서버가 클라이언트에 할당한 고유 식별자. 클라이언트 애플리케이션을 OAuth 서비스에 등록할 때 서비스에서 생성하는 값redirect_uri: 로그인 성공 시 이동해야하는 URIresponse_type: 클라이언트가 제공받길 원하는 응답 타입scope: 제공받고자 하는 리소스 오너의 정보 목록