일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
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 |
- factory
- Dependency Injection
- middleware
- builder
- Spring
- synchronized
- 일급 컬렉션
- java
- lombok
- Volatile
- spring security
- nestjs
- Google OAuth
- 일급 객체
- OAuth 2.0
- Today
- Total
HJW's IT Blog
Spring Security 기반 OAuth 2.0 & JWT 구현하기 본문
이번 사이드 프로젝트를 진행하며, OAuth 2.0 에 더불어 JWT 인증 시스템을 채택하여 진행하였다.
OAuth 2.0 Flow
- Client 의 로그인 요청 : GET /api/oauth2/authorize/{provider} 로 요청을 보낸다
- 서버는 돌아올 콜백 URI 를 포함한, 제공자에 맞는 URL 로 리다이렉션을 한다
- 클라이언트가 제공자의 페이지에서 승인을 한다
- 제공자는 콜백 URI 을 사용해 서버의 엔드포인트에 인증 코드를 포함해 보낸다
- 서버는 해당 인증 코드를 가지고 다시 제공자에 Token요청을 한다
- Token 이 유효하다면, 제공자는 미리 명시된 (ex. email, providerId) 데이터를 서버로 보내준다.
Spring Security 를 사용하게되면, 2 ~ 5번의 과정을 자동으로 진행해 준다. 이때, spring security 가 자동으로 생성하는 콜백 URL 은 다음과 같다 {baseUrl}/login/oauth2/code/google
.
이떄 개발자가 해주어야 할 것은, registration
정보와 provider
정보 입력이다.
다음은 yml 파일의 예시이다.
spring:
security:
oauth2:
client:
registration:
google:
client-id: # Google API Console 에서 발급받은 Client ID
client-secret : # Google API Console 에서 발급받은 Secret
scope : profile, email # 사용자에게 요청할 권한 범위
redirect-uri : "{baseUrl}/login/oauth2/code/google" # 로그인 완료후 Google 이 리다이렉션할 콜백 URI
provider:
google:
authorization-uri : # 사용자를 Google 로그인 화면으로 리다이렉션 하는 URI
token-uri : # 로그인 후 발급받은 code 로 access_token 을 요청하는 URI
user-info-uri : # Access Token 으로 사용자 정보를 가져오는 URI
Google OAuth2.0 설정 가이드
- Google Cloud Console 에서 새 프로젝트 생성
- 접속: 구글 콘솔
- 좌측 상단 메뉴 > 프로젝트 선택 > 새 프로젝트 만들기
- 프로젝트 이름은 자유롭게 입력 (
my-project
,side-project
등) - 프로젝트 생성 후 해당 프로젝트로 전환되어야 이후 작업 가능
- OAuth 동의 화면 구성
- API 및 서비스 -> OAuth 동의 화면
- 사용자 유형 선택 : 개발용이면
외부 사용자
선택 - 필수 정보 입력
- 저장 후 계속
- OAuth 클라이언트 ID 생성
- 사용자 인증 정보 만들기 -> OAuth 클라이언트 ID
- 어플리케이션 유형 선택
- 필수 입력 항목
- 이때, 승인된 리디렉션 URI 는 Spring Security 가 자동으로 사용하는 콜백 URL을 사용
- 혹은 직접 정의해도 되지만, 일치해야 한다.
- Client ID, Secret 발급
지금은 로컬에서 진행하는 프로젝트기 때문에, 승인된 리디렉션 URI 에 Spring Security 가 기본적으로 제공하는 redirect-uri 인 http://localhost:8080/login/oauth2/code/google
를 설정해 두었다.
Spring Security가 자동 생성하는 redirect-uri는 반드시 Google OAuth 설정의 "승인된 리디렉션 URI"와 일치해야 한다.
로그인 처리
여기까지 했다면, 이제 OAuth 인증 성공시, 실패시, Unauthorized api 를 정의해야 한다. 이번 프로젝트엔 /api/oauth/** 와 /api/public/** 의 요청은 인증이 필요 없도록, 그 외의 api 는 인증이 필요하도록 만들 것이다.
JSESSIONID 제거
Spring Security 는 기본적으로 Session 기반 인증을 사용하려 하기 때문에, JSESSIONID
가 발급된다. 이를 클라이언트의 쿠키에 저장하고, 이를 통해 인증 상태를 유지한다. 하지만 이번 프로젝트에선, JWT
를 활용한 인증 방식을 사용할 것이기 때문에 이 JSESSIONID
기반의 인증을 비활성화 해주어야 한다. (이후 알게 되었는데 아직 쿠키 제거가 되진 않지만 인증은 막은 상태이다..)
SessionCreationPolicy.STATELESS
로 설정해 두겠다.
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception{
http
.csrf(csrf -> csrf.disable())
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/oauth2/**", "/api/public/**").permitAll()
.requestMatchers("/api/private/**").authenticated()
.anyRequest().denyAll()
)
.oauth2Login(oauth2 -> oauth2
.successHandler(/* Success Logic */)
.failureHandler(/* Fail Logic */))
.addFilterBefore(/*filter Logic*/);
return http.build();
)
JWT 구현
JWT 를 구현하기 위해 다음 의존성을 추가해 주었다
// jwt
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
implementation 'io.jsonwebtoken:jjwt-impl:0.11.5'
implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5'
1. JwtUtil
JwtUtil
클레스는 다음과 같은 책임을 가질 것이다.
- Access Token 생성
- Refresh Token 생성
- Token 인코딩, 디코딩
우선 static 메소드로 기능들을 구현하기 위해, setter 를 통해 SECRET 을 설정 파일로 부터 주입 받아야 한다
@Value("${secret}")
public void setSecret(String value) {
JwtUtil.SECRET = value;
}
서명 생성
private static Key getSigningKey(){
return Keys.hmacShaKeyFor(SECRET.getBytes());
}
Access Token 생성
public static String generateAccessToken(Long userId, String role){
return Jwts.builder()
.setSubject(String.valueOf(userId))
.claim("role", role)
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + ACCESS_TOKEN_EXPIRATION))
.signWith(getSigningKey())
.compact();
}
- userId 를 subject로, role 을 claim 에 설정하여 Access Token 을 생성해 준다.
- 유효기간은 정의된 ACCESS_TOKEN_EXPIRATION 이며,
getSigningKey()
를 통해 서명 한다.
Refresh Token 생성
public static String generateRefreshToken(Long userId){
return Jwts.builder()
.setSubject(String.valueOf(userId))
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + REFRESH_TOKEN_EXPIRATION))
.signWith(getSigningKey())
.compact();
}
- Refresh Token 의 경우, Access Token 보다 간단하다.
- UserId만 포함된 Refresh Token 을 서명하여 반환하면 끝이다.
parseToken()
public static Claims parseToken(String token){
return Jwts.parserBuilder()
.setSigningKey(getSigningKey())
.build()
.parseClaimsJws(token)
.getBody();
}
- JWT Token 을 파싱하고 내부 Claim 값을 가져온다
2. JwtFilter
이제 Spring Filter 에 등록할 JwtAuthenticationFilter
를 구현해야 한다. 해당 클래스는 모든 요청에 대해 token 을 추출하고, 파싱하여 SecurityContextHolder
에 인증/인가 정보를 저장하는 역할을 할 것이다.
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final MemberRepository memberRepository;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String token = request.getHeader("Authorization");
if(token != null && token.startsWith("Bearer")){
token = token.substring(7);
try{
Claims claims = JwtUtil.parseToken(token);
Long userId = Long.parseLong(claims.getSubject());
Optional<Member> optionalMember = memberRepository.findById(userId);
if(optionalMember.isPresent()){
Member member = optionalMember.get();
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(member, null,
Collections.emptyList());
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}catch (Exception e){
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
return;
}
}
filterChain.doFilter(request, response);
}
}
Flow 설명
- Request Header 에서
Authorization
값을 가져온 후,Bearer
를 제거하여 순수Token
만 추출한다. - 이전
JwtUtil
에 정의한parseToken()
을 이용하여 토큰을 파싱하고, claim 을 추출한다. - 위에서 추출한 claim 을 통해 userId 를 추출한다
- userId 를 사용하여 DB에 저장된 member 를 조회한다.
- 조회결과가 비어있다면 UNAUTHORIZED 를 반환한다.
- member 조회에 성공하면, 인증 객체를 생성하고,
SecurityContextHolder
에 등록한다.
3. Refresh Token 발급 및 구현
본 프로젝트의 Refresh Token 발급 구조는 다음과 같다.
Client 가 Authorization 헤더에 Access Token 을 전송 -> 만료 상태코드 반환 -> 클라이언트가 토큰 재발급 api 로 refresh token 을 담아서 전송 -> refresh token 이 유효하다면 재발급 -> 유효하지 않다면 Unauthorized -> Client 는 Unauthorized 라면 OAuth2.0 을 이용한 재 로그인
3.1 RefreshToken Entity
우선 RefreshToken 엔티티를 생성해 주겠다. 추후 Redis 등을 이용하여 고도화 할 여지가 있지만 우선은 단순 DB 저장 방식을 사용하겠다.
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class RefreshToken {
@Id
private Long memberId;
@Column(nullable = false)
private String token;
public RefreshToken(Long memberId, String token){
this.memberId = memberId;
this.token = token;
}
public void updateToken(String token){
this.token = token;
}
}
3.2 Controller 정의
Refresh Token 을 이용하여 Access Token 을 재발급받는 엔드포인트 또한 필요하다.
@Controller
@RequiredArgsConstructor
public class RefreshTokenController {
private final RefreshTokenRepository refreshTokenRepository;
@PostMapping("/api/token/refresh")
public ResponseEntity<?> refreshToken(@RequestBody RefreshTokenDTO request){
try{
String refreshToken = request.refreshToken();
Claims claims = JwtUtil.parseToken(refreshToken);
Long userId = Long.parseLong(claims.getSubject());
RefreshToken savedToken = refreshTokenRepository.findById(userId).orElseThrow(
// TODO : 예외 처리
);
String newAccessToken = JwtUtil.generateAccessToken(userId, "ROLE_USER");
String newRefreshToken = JwtUtil.generateRefreshToken(userId);
savedToken.updateToken(newRefreshToken);
refreshTokenRepository.save(savedToken);
RefreshTokenDTO refreshTokenDTO = new RefreshTokenDTO(newRefreshToken);
return ResponseEntity.ok()
.header("Authorization", "Bearer " + newAccessToken)
.body(refreshTokenDTO);
}catch (Exception e){
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body("유효하지 않은 Refresh Token 입니다.");
}
}
}
- 요청 Request 에서 Refresh Token 을 추출한다
- Refresh Token 을 파싱하여 userId를 추출하고, userId로 refreshTokenRepository를 조회한다.
- 유효하다면, 새로운 Access Token 과 Refresh Token 을 발급한다.
- 여기서 Refresh Token 재발급은 개발자의 자유이다.
- 새로 발급받은 토큰을 DB 에 저장하고 사용자에게 새로운 Access Token 과 Refresh Token 을 반환한다.
Security Config & AuthenticationSuccessHandler
OAuth 로그인, JWT 구현까지 완료하였다. 이제, OAuth 인증 성공시 실제 토큰 발급 로직을 작성해야 하는데, AuthenticationSuccessHandler
인터페이스를 구현하여 만들 것이다.
기초 키워드 정리
AuthenticationSuccessHander
란?
- Spring Security에서 인증이 성공했을 때 실행되는 콜백 인터페이스이다.
OAuth2User
란?
public interface OAuth2User extends OAuth2AuthenticatedPrincipal {
}
public interface OAuth2AuthenticatedPrincipal extends AuthenticatedPrincipal {
@Nullable
default <A> A getAttribute(String name) {
return this.getAttributes().get(name);
}
Map<String, Object> getAttributes();
Collection<? extends GrantedAuthority> getAuthorities();
}
위처럼 구현되어 있는 OAuth2
인증 후 사용자 정보를 표현하기 위한 인터페이스 이다.
즉, 인증된 사용자 정보를 Map 형태로 들고 있으며, Authentication.getPrincipal()
로 접근이 가능하다. (Spring 에선 @AuthenticationPrincipal
)
AuthenticationSuccessHandler 구현채
@RequiredArgsConstructor
public class CustomOAuth2AuthenticationSuccessHandler implements AuthenticationSuccessHandler {
private final MemberRepository memberRepository;
private final RefreshTokenRepository refreshTokenRepository;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException, ServletException {
OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal();
String email = oAuth2User.getAttribute("email");
String providerId = oAuth2User.getAttribute("sub");
Member member = memberRepository.findByEmail(email).orElseGet(() ->{
Member newMember = new Member(
email,
null,
email.split("@")[0],
Provider.GOOGLE,
providerId,
MemberStatus.ACTIVE
);
return memberRepository.save(newMember);
});
String accessToken = JwtUtil.generateAccessToken(member.getId(), "ROLE_USER");
String refreshToken = JwtUtil.generateRefreshToken(member.getId());
refreshTokenRepository.save(new RefreshToken(member.getId(), refreshToken));
response.setHeader("Authorization", "Bearer " + accessToken);
response.setHeader("Refresh-Token", refreshToken);
}
}
해당 클래스는, OAuth
인증 후, 후처리 하는 클래스로, Authentication 객체에서 정보를 추출하여 OAuth2User 로 캐스팅 한다.
캐스팅 된 OAuth2User 객체에서 비즈니스 로직상 사용자 생성에 필요한 정보들을 추출한다. 이번 프로젝트에선, email, 그리고 providerId 를 추출하기로 하였다.
추출한 email 을 사용하여 DB 를 조회하고, 없다면 새로운 사용자를 등록한다.
- 이후 다른 OAuth 제공자를 사용하게 된다면 지금처럼 Provider 를 하드코딩 해선 안된다.
사용자를 불러왔거나 생성하였다면, 해당 사용자에 대한 Access Token 및 Refresh Token 을 발급한다.
추가로 알게된 사실
Spring Security 는 OAuth2 로그인 후, 자동으로 redirection 302 를 반환한다. 그렇기 때문에 헤더에 설정한 Authorization
과 Refresh-Token
을 읽을 수 없다.
302 Redirection 은 그저 이 URL
로 이동하라는 신호일 뿐이기 때문이다.
하지만, Redirect 시 URL 에 쿼리스트링으로 토큰과 정보를 붙일 경우, 이를 브라우저가 읽어낼 수 있다.
그래서, CustomOAuth2AuthenticationSuccessHandler
에서 response 에 header 가 아닌 redirect url 을 다음과 같이 설정해 주어야 한다.
String encodedName = URLEncoder.encode(member.getName(), StandardCharsets.UTF_8);
String encodedEmail = URLEncoder.encode(member.getEmail(), StandardCharsets.UTF_8);
String encodedRole = URLEncoder.encode("USER", StandardCharsets.UTF_8);
String redirectUri = "http://localhost:3000/oauth2/redirect"
+ "?token=" + accessToken
+ "&refreshToken=" + refreshToken
+ "&id=" + member.getId()
+ "&name=" + encodedName
+ "&email=" + encodedEmail
+ "&role=" + encodedRole;
response.sendRedirect(redirectUri);
확인 해보기
다음과 같은 엔드포인트를 생성하여 한번 잘 로그인이 되는지 테스트 해보겠다.
@RestController
public class TestController {
@GetMapping("/api/private/check-auth")
public ResponseEntity<String> checkAuth(Authentication authentication) {
if (authentication == null || !authentication.isAuthenticated()) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("인증되지 않은 사용자입니다.");
}
Member principal = (Member) authentication.getPrincipal();
return ResponseEntity.ok("인증된 사용자입니다. principal = " + principal.toString());
}
}
http://localhost:8080/api/oauth2/authorize/google
요청
위와 같이 Authorization Header 와 Refresh-Token 이 잘 설정되어 있는것을 볼 수 있다.
이제 Postman 으로 `/api/private/check-auth` 로 테스트 해보겠다.
헤더 없이
헤더 포함
'SPRING' 카테고리의 다른 글
Spring 테스트 격리와 트랜잭션: @Sql 삽입 데이터가 사라지는 이유 (0) | 2025.04.01 |
---|---|
[Spring Security] OAuth2.0 + JWT 로그인 구조에 Form Login 통합하기 (0) | 2025.03.26 |
JPA 다건 조회 시 프록시 객체가 포함되는 원인 분석 - feat. Persistence Context 출력하기 (0) | 2025.03.12 |
Batch Fetching + Pagination으로 N + 1 해결하기 (0) | 2025.03.04 |
JPA SQL - 가독성 좋게 보기 (0) | 2025.02.19 |