Spring
Spring Security - OAuth2 인증 4
RangA
2023. 6. 13. 10:33
Spring Security에서의 OAuth2 인증
OAuth 2와 JWT를 이용한 샘플 애플리케이션 구현
- 이번에는 Frontend와 Backend가 분리된 CSR(Client Side Rendering) 방식의 애플리케이션에 Google의 OAuth2 인증 시스템을 적용해볼 것
- CSR(Client Side Rendering) 방식의 애플리케이션에 OAuth2 인증 시스템을 도입할 경우에도 마찬가지로 OAuth2 인증 시스템을 통해 인증에 성공한 사용자에 대한 자격 증명 정보를 JWT로 제공해 줄 수 있음
- CSR(Client Side Rendering) 방식의 애플리케이션에 OAuth 2 + JWT를 제대로 잘 적용하기 위해서는 먼저 OAuth 2의 인증 처리 흐름과 JWT를 통한 자격 증명 정보 제공 시점에 대해 이해하는 것이 중요함
Frontend와 Backend 간의 OAuth 2 인증 처리 흐름
- Resource Owner가 웹 브라우저에서 ‘Google 로그인 링크’를 클릭함
- Frontend 애플리케이션에서 Backend 애플리케이션의 http://localhost:8080/oauth2/authorization/google 로 request를 전송하며, 이 URI의 request는 OAuth2LoginAuthenticationFilter 가 처리함
- Google의 로그인 화면을 요청하는 URI로 리다이렉트 함
- 이때 Authorization Server가 Backend 애플리케이션 쪽으로 Authorization Code를 전송할 Redirect URI(http://localhost:8080/login/oauth2/code/google) 를 쿼리 파라미터로 전달함
- Redirect URI는 Spring Security가 내부적으로 제공함
- Google 로그인 화면을 오픈함
- Resource Owner가 Google 로그인 인증 정보를 입력해서 로그인을 수행함
- 로그인에 성공하면 (3)에서 전달한 Backend Redirect URI(http://localhost:8080/login/oauth2/code/google) 로 Authorization Code를 요청함
- Authorization Server가 Backend 애플리케이션에게 Authorization Code를 응답으로 전송함
- Backend 애플리케이션이 Authorization Server에 Access Token을 요청함
- Authorization Server가 Backend 애플리케이션에게 Access Token을 응답으로 전송
- 여기서 Access Token은 Google Resource Server에게 Resource를 요청하는 용도로 사용됨
- Backend 애플리케이션이 Resource Server에 User Info를 요청함
- 여기서의 User Info는 Resource Owner에 대한 이메일 주소, 프로필 정보 등을 의미함
- Resource Server가 Backend 애플리케이션에 User Info를 응답으로 전송함
- Backend 애플리케이션은 JWT로 구성된 Access Token과 Refresh Token을 생성한 후, Frontend 애플리케이션에 JWT(Access Token과 Refresh Token)를 전달하기 위해 Frontend 애플리케이션(http://localhost?access_token={jwt-access-token}&refresh_token={jwt-refresh-token}) 으로 Redirect함
- 동작 흐름이 아주 복잡해 보이지만 (6)부터 (11)까지는 Spring Security에서 내부적으로 알아서 처리해 주기 때문에 기본적으로는 건드릴 필요가 없음
- OAuth 2 인증 처리가 정상적으로 동작하는지 확인하기 위해서는 웹서버에서 실제로 실행되는 Frontend 애플리케이션이 필요함
Frontend 애플리케이션 준비
1. 아파치 웹서버 설치 - Windows OS 사용자
- Windows OS 사용자는 아래의 순서대로 Frontend 애플리케이션 실행 환경을 준비함
- 링크에서 아파치 웹서버를 다운로드함
- 다운로드한 파일의 압축을 해제함
- Apache24 디렉토리를 C:\ 디렉토리로 이동함(최종 경로는 C:\Apache24)
- httpd.conf 파일을 메모장 등의 에디터로 오픈함
- ServerName을 주석 해제하고 수정함
- ServerName localhost:80
- 나머지는 디폴트 값을 그대로 사용
- 윈도우 키 + S를 누른 후, cmd를 검색해서 마우스 오른쪽 버튼을 눌러 명령 프롬프트(cmd) 창을 관리자 모드로 실행함
- 명령 프롬프트 창에서 C:\Apache24\bin 디렉토리로 이동함
- httpd.exe -k install 명령을 입력함
- C:\Apache24/bin 경로 내에 있는 ApacheMonitor.exe를 더블 클릭해서 아파치 웹서버를 실행함
- 아파치 웹서버는 바탕화면 오른쪽 하단의 빠른 실행 창에서 실행/중지할 수 있음
- 웹브라우저에서 http://localhost 로 접속하여 아파치 웹서버가 정상적으로 실행이 되는 것을 확인함
2. Frontend 샘플 애플리케이션을 아파치 웹서버에 배포
세 개 HTML 파일을 에디터로 작성한 후, 각 운영체제에 맞는 경로의 디렉토리로 위치시켜야 함
- Windows OS 사용자 : C:\Apache24\htdocs
- Mac OS 사용자 : /Library/WebServer/Documents
- 현재 위치의 하위 디렉토리가 아닌 루트(/)의 하위 경로
- 해당 위치에서 파일을 직접 생성할 땐 반드시 sudo 명령어를 붙여야 함
- 다른 경로에서 해당 위치로 드래그 앤 드롭을 이용해 이동할 땐 암호와 함께 허용 여부를 확인하는 메시지가 뜰 수 있음
index.html
- index.html에서 Google로 로그인 버튼을 클릭하면 Backend 애플리케이션으로 request가 전송되고, Goolge 로그인 화면이 오픈됨
- index.html에서 Google로 로그인 버튼을 클릭하면 Backend 애플리케이션으로 request가 전송되고, Goolge 로그인 화면이 오픈됨
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>OAuth2 + JWT Frontend</title>
</head>
<body>
<h2>Welcome to OAuth 2.0 + JWT Spring Security</h2>
<a href="http://localhost:8080/oauth2/authorization/google">Google로 로그인</a>
</body>
</html>
- receive-token.html
- receive-token.html 은 Backend 애플리케이션에서 전달받은 JWT Access Token과 Refresh Token을 웹브라우저의 LocalStorage에 저장한 후, my-page.html 이동함
- receive-token.html 은 Backend 애플리케이션에서 전달받은 JWT Access Token과 Refresh Token을 웹브라우저의 LocalStorage에 저장한 후, my-page.html 이동함
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>OAuth2 + JWT My page</title>
</head>
<body>
<script type="text/javascript">
let accessToken = (new URL(location.href)).searchParams.get('access_token');
let refreshToken = (new URL(location.href)).searchParams.get('refresh_token');
localStorage.setItem("accessToken", accessToken)
localStorage.setItem("refreshToken", refreshToken)
location.href = 'my-page.html'
</script>
</body>
</html>
- my-page.html
- my-page.html에서는 LocalStorage에 저장된 JWT Access Token과 Refresh Token을 로드해서 웹 브라우저에 표시함
- my-page.html에서는 LocalStorage에 저장된 JWT Access Token과 Refresh Token을 로드해서 웹 브라우저에 표시함
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>OAuth2 + JWT My page</title>
</head>
<body>
<h2>My Page</h2>
<h3>아래의 토큰을 이용해서 Backend 애플리케이션의 리소스를 요청할 수 있습니다.</h3>
<p>
<span>Access Token: </span><span id="accessToken" style="color: blue"></span>
</p>
<p>
<span>Refresh Token: </span><span id="refreshToken" style="color: blue"></span>
</p>
<script type="text/javascript">
let accessToken = localStorage.getItem('accessToken')
let refreshToken = localStorage.getItem('refreshToken');
document.getElementById("accessToken").textContent = accessToken;
document.getElementById("refreshToken").textContent = refreshToken;
</script>
</body>
</html>
Backend 애플리케이션에 OAuth 2 인증 기능 적용
1. JwtTokenizer 추가
- 제일 먼저 할 일은 JWT를 생성하고 JWT를 검증해 주는 JwtTokenizer를 구현하는 것
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jws;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.io.Encoders;
import io.jsonwebtoken.security.Keys;
import lombok.Getter;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.nio.charset.StandardCharsets;
import java.security.Key;
import java.util.Calendar;
import java.util.Date;
import java.util.Map;
@Component
public class JwtTokenizer {
@Getter
@Value("${jwt.key.secret}")
private String secretKey;
@Getter
@Value("${jwt.access-token-expiration-minutes}")
private int accessTokenExpirationMinutes;
@Getter
@Value("${jwt.refresh-token-expiration-minutes}")
private int refreshTokenExpirationMinutes;
public String encodeBase64SecretKey(String secretKey) {
return Encoders.BASE64.encode(secretKey.getBytes(StandardCharsets.UTF_8));
}
public String generateAccessToken(Map<String, Object> claims,
String subject,
Date expiration,
String base64EncodedSecretKey) {
Key key = getKeyFromBase64EncodedKey(base64EncodedSecretKey);
return Jwts.builder()
.setClaims(claims)
.setSubject(subject)
.setIssuedAt(Calendar.getInstance().getTime())
.setExpiration(expiration)
.signWith(key)
.compact();
}
public String generateRefreshToken(String subject, Date expiration, String base64EncodedSecretKey) {
Key key = getKeyFromBase64EncodedKey(base64EncodedSecretKey);
return Jwts.builder()
.setSubject(subject)
.setIssuedAt(Calendar.getInstance().getTime())
.setExpiration(expiration)
.signWith(key)
.compact();
}
// 검증 후, Claims을 반환하는 용도
public Jws<Claims> getClaims(String jws, String base64EncodedSecretKey) {
Key key = getKeyFromBase64EncodedKey(base64EncodedSecretKey);
Jws<Claims> claims = Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(jws);
return claims;
}
// 단순히 검증만 하는 용도로 쓰일 경우
public void verifySignature(String jws, String base64EncodedSecretKey) {
Key key = getKeyFromBase64EncodedKey(base64EncodedSecretKey);
Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(jws);
}
public Date getTokenExpiration(int expirationMinutes) {
Calendar calendar = Calendar.getInstance();
calendar.add(Calendar.MINUTE, expirationMinutes);
Date expiration = calendar.getTime();
return expiration;
}
private Key getKeyFromBase64EncodedKey(String base64EncodedSecretKey) {
byte[] keyBytes = Decoders.BASE64.decode(base64EncodedSecretKey);
Key key = Keys.hmacShaKeyFor(keyBytes);
return key;
}
}
2. application.yml 설정
spring:
h2:
console:
enabled: true
path: /h2
datasource:
url: jdbc:h2:mem:test
jpa:
hibernate:
ddl-auto: create # (1) 스키마 자동 생성
show-sql: true # (2) SQL 쿼리 출력
properties:
hibernate:
format_sql: true # (3) SQL pretty print
sql:
init:
data-locations: classpath*:db/h2/data.sql
security:
oauth2:
client:
registration:
google:
clientId: ${G_CLIENT_ID}
clientSecret: ${G_CLIENT_SECRET}
scope:
- email // (1)
- profile // (2)
logging:
level:
org:
springframework:
orm:
jpa: DEBUG
server:
servlet:
encoding:
force-response: true
mail:
address:
admin: admin@gmail.com
jwt:
key:
secret: ${JWT_SECRET_KEY} # 민감한 정보는 시스템 환경 변수에서 로드함
access-token-expiration-minutes: 30
refresh-token-expiration-minutes: 420
- (1), (2)와 같이 scope 값을 직접 지정하면 해당 범위만큼의 Resource를 Client(백엔드 애플리케이션)에 제공함
- (1)은 Resource Owner의 이메일 정보를 의미하고, (2)는 Resource Owner의 프로필 정보를 의미함
3. JwtVerificationFilter 추가
- JwtVerificationFilter는 OAuth 2 인증에 성공하면 Frontend 애플리케이션 쪽에서 request를 전송할 때마다 Authorization header에 실어 보내는 Access Token에 대한 검증을 수행하는 Filter
import com.codestates.auth.jwt.JwtTokenizer;
import com.codestates.auth.utils.CustomAuthorityUtils;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.List;
import java.util.Map;
public class JwtVerificationFilter extends OncePerRequestFilter { // (1)
private final JwtTokenizer jwtTokenizer;
private final CustomAuthorityUtils authorityUtils;
// (2)
public JwtVerificationFilter(JwtTokenizer jwtTokenizer,
CustomAuthorityUtils authorityUtils) {
this.jwtTokenizer = jwtTokenizer;
this.authorityUtils = authorityUtils;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
Map<String, Object> claims = verifyJws(request); // (3)
setAuthenticationToContext(claims); // (4)
filterChain.doFilter(request, response); // (5)
}
// (6)
@Override
protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException {
String authorization = request.getHeader("Authorization"); // (6-1)
return authorization == null || !authorization.startsWith("Bearer"); // (6-2)
}
private Map<String, Object> verifyJws(HttpServletRequest request) {
String jws = request.getHeader("Authorization").replace("Bearer ", ""); // (3-1)
String base64EncodedSecretKey = jwtTokenizer.encodeBase64SecretKey(jwtTokenizer.getSecretKey()); // (3-2)
Map<String, Object> claims = jwtTokenizer.getClaims(jws, base64EncodedSecretKey).getBody(); // (3-3)
return claims;
}
private void setAuthenticationToContext(Map<String, Object> claims) {
String username = (String) claims.get("username"); // (4-1)
List<GrantedAuthority> authorities = authorityUtils.createAuthorities((List)claims.get("roles")); // (4-2)
Authentication authentication = new UsernamePasswordAuthenticationToken(username, null, authorities); // (4-3)
SecurityContextHolder.getContext().setAuthentication(authentication); // (4-4)
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
// (1)
try {
Map<String, Object> claims = verifyJws(request);
setAuthenticationToContext(claims);
} catch (SignatureException se) {
request.setAttribute("exception", se);
} catch (ExpiredJwtException ee) {
request.setAttribute("exception", ee);
} catch (Exception e) {
request.setAttribute("exception", e);
}
filterChain.doFilter(request, response);
}
}
4. AuthenticationSuccessHandler 구현
- AuthenticationSuccessHandler는 OAuth 2 인증에 성공하면 호출되는 핸들러
- 여기에서 JWT를 생성하고, Frontend 쪽으로 JWT를 전송하기 위해 Redirect 하는 로직을 구현함
- OAuth2MemberSuccessHandler 클래스는 OAuth 2 인증 후, Frontend 애플리케이션 쪽으로 JWT를 전송하는 핵심 역할을 담당함
import com.codestates.member.entity.Member;
import com.codestates.member.service.MemberService;
import com.codestates.oauth2_jwt.jwt.JwtTokenizer;
import com.codestates.oauth2_jwt.utils.CustomAuthorityUtils;
import com.codestates.stamp.Stamp;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.util.UriComponentsBuilder;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.net.URI;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
public class OAuth2MemberSuccessHandler extends SimpleUrlAuthenticationSuccessHandler { // (1)
private final JwtTokenizer jwtTokenizer;
private final CustomAuthorityUtils authorityUtils;
private final MemberService memberService;
// (2)
public OAuth2MemberSuccessHandler(JwtTokenizer jwtTokenizer,
CustomAuthorityUtils authorityUtils,
MemberService memberService) {
this.jwtTokenizer = jwtTokenizer;
this.authorityUtils = authorityUtils;
this.memberService = memberService;
}
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
var oAuth2User = (OAuth2User)authentication.getPrincipal();
String email = String.valueOf(oAuth2User.getAttributes().get("email")); // (3)
List<String> authorities = authorityUtils.createRoles(email); // (4)
saveMember(email); // (5)
redirect(request, response, email, authorities); // (6)
}
private void saveMember(String email) {
Member member = new Member(email);
member.setStamp(new Stamp());
memberService.createMember(member);
}
private void redirect(HttpServletRequest request, HttpServletResponse response, String username, List<String> authorities) throws IOException {
String accessToken = delegateAccessToken(username, authorities); // (6-1)
String refreshToken = delegateRefreshToken(username); // (6-2)
String uri = createURI(accessToken, refreshToken).toString(); // (6-3)
getRedirectStrategy().sendRedirect(request, response, uri); // (6-4)
}
private String delegateAccessToken(String username, List<String> authorities) {
Map<String, Object> claims = new HashMap<>();
claims.put("username", username);
claims.put("roles", authorities);
String subject = username;
Date expiration = jwtTokenizer.getTokenExpiration(jwtTokenizer.getAccessTokenExpirationMinutes());
String base64EncodedSecretKey = jwtTokenizer.encodeBase64SecretKey(jwtTokenizer.getSecretKey());
String accessToken = jwtTokenizer.generateAccessToken(claims, subject, expiration, base64EncodedSecretKey);
return accessToken;
}
private String delegateRefreshToken(String username) {
String subject = username;
Date expiration = jwtTokenizer.getTokenExpiration(jwtTokenizer.getRefreshTokenExpirationMinutes());
String base64EncodedSecretKey = jwtTokenizer.encodeBase64SecretKey(jwtTokenizer.getSecretKey());
String refreshToken = jwtTokenizer.generateRefreshToken(subject, expiration, base64EncodedSecretKey);
return refreshToken;
}
private URI createURI(String accessToken, String refreshToken) {
MultiValueMap<String, String> queryParams = new LinkedMultiValueMap<>();
queryParams.add("access_token", accessToken);
queryParams.add("refresh_token", refreshToken);
return UriComponentsBuilder
.newInstance()
.scheme("http")
.host("localhost")
// .port(80)
.path("/receive-token.html")
.queryParams(queryParams)
.build()
.toUri();
}
}
- (1)과 같이 SimpleUrlAuthenticationSuccessHandler를 상속하면 Redirect를 손쉽게 할 수 있는 getRedirectStrategy().sendRedirect() 같은 API를 사용할 수 있음
- (2)와 같이 필요한 객체를 DI 받음
- (3)에서는 Authentication 객체로부터 얻어낸 OAuth2User 객체로부터 Resource Owner의 이메일 주소를 얻음
- (4)에서는 CustomAuthorityUtils를 이용해 권한 정보를 생성함
- (5)에서는 Resource Owner의 이메일 주소를 DB에 저장함
- OAuth 2의 특성상 Resource Owner의 크리덴셜(Credential)을 Backend 애플리케이션에서 관리하지는 않지만 Backend 애플리케이션의 Resource와 연관 관계를 맺기 위해서 최소한의 정보는 Backend 애플리케이션 쪽에서 관리해도 무방함
- (6)에서는 Access Token과 Refresh Token을 생성해서 Frontend 애플리케이션에 전달하기 위해 Redirect함
- (6-1)과 (6-2)에서는 JWT Access Token과 Refresh Token을 생성함
- (6-3)에서는 Frontend 애플리케이션 쪽의 URL을 생성함
- createURI() 메서드에서 UriComponentsBuilder를 이용해 Access Token과 Refresh Token을 포함한 URL을 생성하고 있음
- UriComponentsBuilder에서 Port 설정을 하지 않으면 기본값은 80 포트
- (6-4)에서는 SimpleUrlAuthenticationSuccessHandler에서 제공하는 sendRedirect() 메서드를 이용해 Frontend 애플리케이션 쪽으로 리다이렉트 함
SecurityConfigiguration 설정
import com.codestates.member.service.MemberService;
import com.codestates.oauth2_jwt.auth.filter.JwtVerificationFilter;
import com.codestates.oauth2_jwt.auth.handler.MemberAccessDeniedHandler;
import com.codestates.oauth2_jwt.auth.handler.MemberAuthenticationEntryPoint;
import com.codestates.oauth2_jwt.jwt.JwtTokenizer;
import com.codestates.oauth2_jwt.auth.handler.OAuth2MemberSuccessHandler;
import com.codestates.oauth2_jwt.utils.CustomAuthorityUtils;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.oauth2.client.web.OAuth2LoginAuthenticationFilter;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.Arrays;
import static org.springframework.security.config.Customizer.withDefaults;
@Configuration
public class SecurityConfiguration {
private final JwtTokenizer jwtTokenizer;
private final CustomAuthorityUtils authorityUtils;
private final MemberService memberService;
public SecurityConfiguration(JwtTokenizer jwtTokenizer,
CustomAuthorityUtils authorityUtils,
MemberService memberService) {
this.jwtTokenizer = jwtTokenizer;
this.authorityUtils = authorityUtils;
this.memberService = memberService;
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.headers().frameOptions().sameOrigin()
.and()
.csrf().disable()
.cors(withDefaults())
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.formLogin().disable()
.httpBasic().disable()
.exceptionHandling() // 추가
.authenticationEntryPoint(new MemberAuthenticationEntryPoint()) // 추가
.accessDeniedHandler(new MemberAccessDeniedHandler()) // 추가
.and()
.apply(new CustomFilterConfigurer()) // 추가
.and()
.authorizeHttpRequests(authorize -> authorize // url authorization 전체 추가
// .antMatchers(HttpMethod.POST, "/*/members").permitAll() // OAuth 2로 로그인하므로 회원 정보 등록 필요 없음.
// .antMatchers(HttpMethod.PATCH, "/*/members/**").hasRole("USER") // OAuth 2로 로그인하므로 회원 정보 수정 필요 없음.
// .antMatchers(HttpMethod.GET, "/*/members").hasRole("ADMIN") // OAuth 2로 로그인하므로 회원 정보 수정 필요 없음.
// .antMatchers(HttpMethod.GET, "/*/members/**").hasAnyRole("USER", "ADMIN") // OAuth 2로 로그인하므로 회원 정보 수정 필요 없음.
// .antMatchers(HttpMethod.DELETE, "/*/members/**").hasRole("USER") // OAuth 2로 로그인하므로 회원 정보 수정 필요 없음.
.antMatchers(HttpMethod.POST, "/*/coffees").hasRole("ADMIN")
.antMatchers(HttpMethod.PATCH, "/*/coffees/**").hasRole("ADMIN")
.antMatchers(HttpMethod.GET, "/*/coffees/**").hasAnyRole("USER", "ADMIN")
.antMatchers(HttpMethod.GET, "/*/coffees").permitAll()
.antMatchers(HttpMethod.DELETE, "/*/coffees").hasRole("ADMIN")
.antMatchers(HttpMethod.POST, "/*/orders").hasRole("USER")
.antMatchers(HttpMethod.PATCH, "/*/orders").hasAnyRole("USER", "ADMIN")
.antMatchers(HttpMethod.GET, "/*/orders/**").hasAnyRole("USER", "ADMIN")
.antMatchers(HttpMethod.DELETE, "/*/orders").hasRole("USER")
.anyRequest().permitAll()
)
.oauth2Login(oauth2 -> oauth2
.successHandler(new OAuth2MemberSuccessHandler(jwtTokenizer, authorityUtils, memberService)) // (1)
);
return http.build();
}
@Bean
CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(Arrays.asList("*"));
configuration.setAllowedMethods(Arrays.asList("GET","POST", "PATCH", "DELETE"));
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("\/**", configuration); // 주의 사항: 컨텐츠 표시 오류로 인해 '/**'를 '\/**'로 표기했으니 실제 코드 구현 시에는 '\(역슬래시)'를 빼야 함
return source;
}
// 추가
public class CustomFilterConfigurer extends AbstractHttpConfigurer<CustomFilterConfigurer, HttpSecurity> {
@Override
public void configure(HttpSecurity builder) throws Exception {
JwtVerificationFilter jwtVerificationFilter = new JwtVerificationFilter(jwtTokenizer, authorityUtils);
builder.addFilterAfter(jwtVerificationFilter, OAuth2LoginAuthenticationFilter.class); // (2)
}
}
}
- (1)에서는 OAuth2 로그인 설정에 .successHandler()를 통해 OAuth2 인증이 성공한 뒤 실행되는 핸들러를 추가함
- OAuth2MemberSuccessHandler 객체를 생성하면서 OAuth2MemberSuccessHandler에서 필요한 의존 객체를 DI 하고 있는 것을 확인할 수 있음
- (2)와 같이 JwtVerificationFilter를 OAuth2LoginAuthenticationFilter 뒤에 추가함
- OAuth 2 인증을 사용하므로 Backend 애플리케이션 쪽에서는 MemberController를 사용할 일이 현재로서는 없으므로 MemberController의 핸들러 메서드 쪽 URI에 대한 접근 권한은 주석 처리함
5. 기타 수정된 코드
- 기타 수정된 코드는 회원 정보와 관련된 코드
- 제삼자인 써드 파티 애플리케이션(Google 서비스)의 OAuth 2 인증 시스템을 사용하기 때문에 회원 정보를 등록하거나 수정할 필요가 없으므로 이와 관련된 MemberController, MemberDto, MemberService, Member 엔티티 클래스에서 회원 정보를 등록 및 수정하는 로직의 대부분이 제거되거나 수정해야 함
애플리케이션 테스트
- Backend 애플리케이션을 IDE에서 실행하고, Frontend 쪽 아파치 웹서버가 실행되어 있는지 확인한 후, 웹 브라우저에 http://localhost 를 입력해서 Frontend 애플리케이션의 화면을 오픈함
- Frontend 애플리케이션의 메인 페이지에서 구글 로그인 인증 화면이 표시되고, 로그인 인증에 성공하면 JWT의 Access Token과 Refresh Token이 표시되어야 함
- JWT Access Token은 Backend 애플리케이션의 Resource를 요청하는 핸들러 메서드를 호출할 때 Authorization header에 추가해서 사용하며, Refresh Token은 Access Token이 만료되었을 때, Access Token을 새로 발급 받고자 할 때 사용할 수 있음
- Google과 같은 OAuth2 인증 시스템을 자체적으로 구축하기 위해 Authorization Server와 Resource Server를 구현할 수도 있음
- 이 경우, Authorization Server와 Resource Server 간에도 JWT를 이용할 수 있으며, Spring Security에서는 Authorization Server와 Resource Server 간의 통신에 JWT를 이용할 수 있는 API를 제공함
- JWT에 대한 서명을 대칭키 방식으로 진행했지만 Authorization Server와 Resource Server 간에 주고받는 JWT의 보안성을 강화하기 위해 비대칭키 방식의 서명도 사용할 수 있음
- 비대칭키로 JWT를 암복호화하는 방식은 OAuth2MemberSuccessHandler에서 Frontend 애플리케이션 쪽으로 JWT를 쿼리파라미터로 추가한 뒤 리다이렉트 할 경우에도 사용할 수 있음