[Springboot] OAuth2 와 JWT (4) 세션 유지(JWT)
이전 글 - db 연동 편 에서 이어집니다.
구현
springboot를 이용해 OAuth2.0 과 JWT를 이용해 로그인을 구현합니다.
클라이언트는 별도 구성 없이 springboot에서 thymeleaf를 이용해 구현합니다
환경
환경 | 버전 |
---|---|
java | 11 |
springboot | 2.7.0 |
gradle | 7.4.1 |
Step 3. 로그인 세션 유지 (JWT)
1. Token 생성 서비스 구현
의존성 추가
build.gradle
dependencies {
// BE
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'io.jsonwebtoken:jjwt:0.9.1'
implementation 'javax.xml.bind:jaxb-api' // DatatypeConverter 사용을 위해 추가
...
}
JWT 서명을 위한 개인키를 설정값에 추가
- /src/main/resources/private.yml
private.jwt.secret 에 원하는 개인 키 값을 작성합니다. 유출되면 인증이 탈탈 털리는 값입니다.
private:
key:
oauth:
github:
clientId:
clientSecret:
jwt:
secret:
Token 객체 추가
jwt/Token.java
@Getter
public class Token {
private final String token;
public Token(String token) {
this.token = token;
}
}
JwtSubject 객체 추가 (이건 필수가 아니다)
jwt/JwtSubject.java
필수적인 요소만 넣으려고 했는데, token 발급 데이터를 oAuthId 로 잡은 이상 registrationId 가 같이 가야한다.
Claims는 subject를 하나만 가져 registrationId 와 oAuthId를 뭉쳐서 처리해주는 객체이다.
이걸 안 쓰고 싶다면 tokenService에서 oAuthId만 쓰거나 email 같은 걸 쓰면 된다. unique 하지 않을 수 있어서 subject를 쓰는 것이 더 안전하다.
@Getter
@RequiredArgsConstructor
public class JwtSubject {
private final String registrationId;
private final String oAuthId;
public JwtSubject(String jwtSubjectString) {
String[] strings = jwtSubjectString.split("\t");
if(strings.length != 2) {
throw new IllegalArgumentException("잘못된 형식의 subject 입니다. subject : " + jwtSubjectString);
}
registrationId = strings[0];
oAuthId = strings[1];
}
public String toString() {
return registrationId + "\t" + oAuthId;
}
}
Token 발급 서비스 추가
jwt/TokenService.java
verifyToken()의 JWS와 claims가 기억이 안 난다면 JWT의 개념을 다시 보자.
대충 claims 는 내용물, JWS는 서명이 붙은 json 이라고 보면 된다.
@Getter
@Slf4j
@Service
public class TokenService {
@Value("${private.key.jwt.secret}")
private String secretKey; // 토큰 서명용 개인키. 그대로 쓰지 않고 Base64 인코딩하여 사용한다.
private long tokenPeriod = 1000L * 60L * 10L; // 토큰 만료 시간
@PostConstruct
protected void init() {
if (secretKey == null) { // Bean 이기 때문에 개인키 주입이 제대로 안 되면 최초 실행 중에 죽는다.
throw new RuntimeException("Fail to inject configuration value");
}
secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes());
}
/**
* 토큰 생성
* @param registrationId oauth 를 이용한 서비스 id. 복합키의 구성요소
* @param oAuthId oauth 로그인 시 제공받은 키 값. registrationId와 조합하면 키 값이 된다.
* @param role 사용자 권한. 우리는 일반 사용자만 있다.
* @return 생성된 토큰
*/
public Token generateToken(String registrationId, String oAuthId, Role role) {
JwtSubject jwtSubject = new JwtSubject(registrationId, oAuthId);
Claims claims = Jwts.claims().setSubject(jwtSubject.toString());
claims.put("role", role.getKey());
Date now = new Date();
return new Token(
Jwts.builder()
.setClaims(claims)
.setIssuedAt(now)
.setExpiration(new Date(now.getTime() + tokenPeriod))
.signWith(SignatureAlgorithm.HS256, secretKey)
.compact());
}
/**
* 토큰이 유효한지 확인. 만료기한 뿐만 아니라, 토큰의 포맷, 사인, 토큰 내용 등을 확인한다.
* @param token JWT 를 담은 문자열
* @return 유효 여부
*/
public boolean verifyToken(String token) {
try {
Jws<Claims> claimsJws = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token);
return claimsJws.getBody().getExpiration().after(new Date());
} catch (Exception e) {
log.info("만료되었거나 올바르지 않은 토큰입니다" + token);
return false;
}
}
/**
* token 에서 claim 의 subject를 가져온다.
* 우리의 subject 는 registrationId + \t + oAuthId 의 구조를 갖는다.
* @param token 우리가 만든 jwt token
* @return token 에서 추출한 subject
*/
public String getClaimsSubject(String token) {
return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody().getSubject();
}
}
여기까지 잘 되었는지 테스트 : 테스트 코드 작성 - 아래의 테스트가 모두 성공해야 한다.
개인키 주입으로 인해 슬라이스 테스트는 안 된다.
/src/test/java/me/cherrue/prototypeoauthjwt/jwt/TokenServiceTest.java
경로가 test를 제외하고는 TokenService.java 와 같아야한다. SpringBootTest의 규칙이다.
verifyToken 테스트 시 java reflection 을 사용하기보다 test용 application.yml 을 만드는 것이 어떤가 생각만 해보았다.
@SpringBootTest
class TokenServiceTest {
@Autowired
TokenService tokenService;
@Test
@DisplayName("private.yml 의 secret key 가 정상적으로 주입되었는지 확인")
public void checkConfigurationDI() {
assertThat(tokenService.getSecretKey()).isNotNull();
}
@Test
@DisplayName("jwt 토큰 생성 확인")
/*
* JWT 는 .을 기준으로 세 부분을 나뉘고, 각 부분은 BASE64 인코딩이 되어있다.
* 첫 부분은 암호화 형식을, 두 번째는 claims 을, 세 번째는 서명을 담는다.
*/
public void generateToken() {
String registrationId = "github";
String oAuthId = "1234";
String algorithm = "HS256";
Token token = tokenService.generateToken(registrationId, oAuthId, Role.ROLE_USER);
System.out.println(token.getToken());
String[] strings = token.getToken().split("\\.");
assertThat(strings.length).isEqualTo(3);
String header = new String(Base64.getDecoder().decode(strings[0]));
String payload = new String(Base64.getDecoder().decode(strings[1]));
assertThat(header).contains(algorithm);
assertThat(payload).contains(registrationId).contains(oAuthId).contains(Role.ROLE_USER.getKey());
}
@Test
@DisplayName("만료된 토큰에 대한 verifyToken 함수 동작 확인")
/*
* java reflection 을 사용하 private 한 tokenPeriod 를 잠시 1초로 바꾸었다.
*/
public void verifyToken() throws InterruptedException {
Object tokenPeriod = ReflectionTestUtils.getField(tokenService, "tokenPeriod");
ReflectionTestUtils.setField(tokenService, "tokenPeriod", 1000L);
Token token = tokenService.generateToken("registrationId", "oAuthId", Role.ROLE_USER);
Thread.sleep(1000L);
boolean isExpired = tokenService.verifyToken(token.getToken());
assertThat(isExpired).isFalse();
ReflectionTestUtils.setField(tokenService, "tokenPeriod", tokenPeriod);
}
}
2. 토큰 발급 handler 구현
oauth/OAuthSuccessHandler.java
oauth 로그인 성공 시 동작하는 핸들러이다.
토큰을 발급해 cookie 에 담아주자.
그 외 기능은 기존과 같게 동작하도록 SavedRequestAwareAuthenticationSuccessHandler
를 상속받는다.
@Slf4j
@Component
@RequiredArgsConstructor
public class OAuthSuccessHandler implements SavedRequestAwareAuthenticationSuccessHandler {
private final TokenService tokenService;
private final ObjectMapper objectMapper;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException {
OAuth2User principal = (OAuth2User) authentication.getPrincipal();
Map<String, Object> attributes = principal.getAttributes();
// CustomOAuth2UserService 에서 UserDto.toMap 해서 넘겼기 때문에 키 값이 고정이다.
UserDto userDto = UserDto.builder()
.registrationId((String) attributes.get("registrationId"))
.oAuthId(String.valueOf(attributes.get("id"))) // Integer
.name((String) attributes.get("name"))
.email((String) attributes.get("email"))
.image((String) attributes.get("image"))
.build();
Token token = tokenService.generateToken(userDto.getRegistrationId(), userDto.getOAuthId(), Role.ROLE_USER);
log.info(token.getToken());
Cookie jwtCookie = new Cookie("jwt", token.getToken());
jwtCookie.setMaxAge(-1); // 음수이면 브라우저를 닫으면 날아간다.
jwtCookie.setPath("/");
response.addCookie(jwtCookie);
// 기존의 redirect 수행
super.onAuthenticationSuccess(request, response, authentication);
}
구현한 핸들러를 등록한다.
config/SecurityConfiguration.java
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfiguration {
private final CustomOAuth2UserService customOAuth2UserService;
private final OAuthSuccessHandler oAuthSuccessHandler;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
// @formatter:off
http
...
.oauth2Login(o -> o
.successHandler(oAuthSuccessHandler)
.userInfoEndpoint()
.userService(customOAuth2UserService));
// @formatter:on
return http.build();
}
...
}
여기까지 잘 되었는지 테스트 : 로그인 시 쿠키에 jwt가 저장된다. 콘솔 로그와 비교하여 같은지 확인한다.
로그인 후 콘솔 : 개발자 도구(F12) > Application > Cookies > localhost:8080 > jwt
콘솔 로그
3. 토큰 인증 필터 구현
jwt 인증 필터
jwt/JwtFilter.java
쿠키에 담긴 jwt 값을 가져와 검증하고, 가입한 사용자인지 검사한 후 인증해준다.
JwtFilter를 지나가면 UsernamePassword를 패스하도록 UsernamePasswordAuthenticationToken를 설정해준다.
@RequiredArgsConstructor
public class JwtFilter extends GenericFilterBean {
private final TokenService tokenService;
private final MemberRepository memberRepository;
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
String token = null;
Cookie[] cookies = ((HttpServletRequest) request).getCookies();
if (cookies != null) {
// jwt 라는 이름으로 담긴 쿠키를 찾는다.
token = Arrays.stream(cookies).filter(c -> c.getName().equals("jwt")).findFirst().map(Cookie::getValue).orElse(null);
}
if (token != null && tokenService.verifyToken(token)) {
JwtSubject jwtSubject = new JwtSubject(tokenService.getClaimsSubject(token));
// 회원가입이 되어있는 사용자인 경우에만 인증
memberRepository.findByRegistrationIdAndOAuthId(jwtSubject.getRegistrationId(), jwtSubject.getOAuthId())
.ifPresent(m -> {
Authentication authentication = new UsernamePasswordAuthenticationToken(m, "",
List.of(new SimpleGrantedAuthority(m.getRole().getKey())));
SecurityContextHolder.getContext().setAuthentication(authentication);
});
}
chain.doFilter(request, response);
}
}
만든 필터를 등록하자
config/SecurityConfiguration.java
session이 필요가 없으므로 STATELESS 하게 변경한다.
JwtFilter 를 UsernamePasswordAuthenticationFilter 앞에 붙인다.
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfiguration {
private final CustomOAuth2UserService customOAuth2UserService;
private final OAuthSuccessHandler oAuthSuccessHandler;
private final TokenService tokenService;
private final MemberRepository memberRepository;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
// @formatter:off
http
...
.sessionManagement(s -> s
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
...
// @formatter:on
http.addFilterBefore(new JwtFilter(tokenService, memberRepository), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
...
}
user endpoint 수정
이제 OAuthUser 가 아니라 Member 를 사용하기 때문에 controller를 수정해야 한다.
PrototypeOauthJwtApplication.java
@AuthenticationPrincipal 의 자료형이 바뀌었다.
@RestController
@SpringBootApplication
public class PrototypeOauthJwtApplication {
@GetMapping("/user")
public Map<String, Object> user(@AuthenticationPrincipal Member principal) {
return Collections.singletonMap("name", principal.getName());
}
public static void main(String[] args) {
SpringApplication.run(PrototypeOauthJwtApplication.class, args);
}
}
여기까지 잘 되었는지 테스트 : 로그아웃을 제외한 모든 기능이 정상 동작한다.
- JWT를 쓰면 stateless 한 것이 목표이기 때문에 로그아웃 구현할 수 없습니다. 억지로 구현한다면 jwt를 사용하는 목적과 많이 벗어납니다.
- 로그인, 개발자 도구의 쿠키, 로그인 시 이름 출력이 모두 정상적으로 동작해야 합니다.
댓글남기기