
이번 프로젝트에서 회원가입/로그인 기능을 Spring Security OAuth2를 활용하여 리소스 서버에서 개인정보를 조회하여 처리할 예정입니다.
이를 위해 핵심이 되는 기술은 다음과 같습니다.
구현 예정인 소셜 로그인 종류로는 Google, Kakao, Naver 가 있으며, 이번 포스팅에서는 Google 관련 소셜 로그인 기능을 구현하겠습니다.
OpenID Connect | Authentication | Google for Developers
애플리케이션(서비스)에서 사용자 로그인을 위해 Google의 OAuth2.0 인증 시스템을 사용하려면 Google API Console에서 프로젝트를 설정하여 OAuth2.0 사용자 인증 정보를 가져오도록 하고, 리다이렉션 URI를 설정하고 선택적으로 사용자 동의 화면에 표시되는 브랜드 정보를 맞춤 설정해줘야 합니다.

프로젝트 생성하기
Google Cloud > API 및 서비스 > 프로젝트 생성

OAuth 동의 화면 생성
OAuth 동의 화면

범위 추가 또는 삭제 선택

범위 선택하기

테스트 사용자 등록하기

요약하기

사용자 인증 정보 - 클라이언트 ID 생성
OAuth API 호출을 위한 클라이언트 키 발급.



구글 설정을 마쳤으면, 회원 정보를 담을 JWT 토큰을 생성하고 처리하는 로직을 구현하겠습니다.
build.gradle (jwt 버전 - 공식 깃헙 참고)
// JWT
implementation 'io.jsonwebtoken:jjwt-api:0.12.6'
implementation 'io.jsonwebtoken:jjwt-impl:0.12.6'
implementation 'io.jsonwebtoken:jjwt-jackson:0.12.6'
JWT 생성
@Component
@Slf4j
public class TokenProvider {
private final SecretKey ACCESS_SECRET;
private final SecretKey REFRESH_SECRET;
private final Long ACCESS_EXP;
private final Long REFRESH_EXP;
private final AuthProvider authProvider;
private static final String Bearer = "Bearer ";
private static final String ROLE_CLAIM = "role";
public TokenProvider(
@Value("${jwt.access.secret}") String accessSecret,
@Value("${jwt.access.expired}") Long accessExp,
@Value("${jwt.refresh.secret}") String refreshSecret,
@Value("${jwt.refresh.expired}") Long refreshExp,
AuthProvider authProvider
) {
this.authProvider = authProvider;
byte[] accessKeyBytes = Decoders.BASE64.decode(accessSecret);
byte[] refreshKeyBytes = Decoders.BASE64.decode(refreshSecret);
this.ACCESS_SECRET = Keys.hmacShaKeyFor(accessKeyBytes);
this.REFRESH_SECRET = Keys.hmacShaKeyFor(refreshKeyBytes);
this.ACCESS_EXP = accessExp;
this.REFRESH_EXP = refreshExp;
}
public String createAccessToken(Authentication authentication) {
return createToken(authentication, ACCESS_SECRET, ACCESS_EXP);
}
public String createAccessToken(String refreshToken) {
Claims claims = getClaimsFromRefreshToken(refreshToken);
Authentication authentication = authProvider.getAuthentication(claims);
return createAccessToken(authentication);
}
public String createRefreshToken(Authentication authentication) {
return createToken(authentication, REFRESH_SECRET, REFRESH_EXP);
}
public String createRefreshToken(String accessToken) {
Claims claims = getClaimsFromAccessToken(accessToken);
Authentication authentication = authProvider.getAuthentication(claims);
return createRefreshToken(authentication);
}
private String createToken(Authentication authentication, SecretKey secret, Long exp) {
Date now = new Date();
Date expiredDate = new Date(now.getTime() + exp);
String authorities = authentication.getAuthorities()
.stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining());
return Jwts.builder()
.subject(authentication.getName())
.claim(ROLE_CLAIM, authorities)
.issuedAt(now)
.expiration(expiredDate)
.signWith(secret)
.compact();
}
...
}
JWT 파싱
@Component
@Slf4j
public class TokenProvider {
...
public Claims getClaimsFromAccessToken(String accessToken) {
return getClaimsFromToken(accessToken, ACCESS_SECRET);
}
public Claims getClaimsFromRefreshToken(String refreshToken) {
return getClaimsFromToken(refreshToken, REFRESH_SECRET);
}
// JWT 페이로드에 담긴 claims 조회
private Claims getClaimsFromToken(String token, SecretKey secretKey) {
try {
return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload();
} catch (ExpiredJwtException e) {
throw new ExpiredTokenException();
} catch (SignatureException e) {
throw new InvalidJwtSignatureException();
} catch (JwtException e) {
throw new InvalidTokenException(e.getMessage());
} catch (Exception e) {
throw new UnAuthenticatedException(e.getMessage());
}
}
...
}
JWT 검증
// token validation
public void validateAccessToken(String accessToken) {
validateToken(accessToken, ACCESS_SECRET);
}
public void validateRefreshToken(String refreshToken) {
validateToken(refreshToken, REFRESH_SECRET);
}
private void validateToken(String token, SecretKey secretKey) {
this.getClaimsFromToken(token, secretKey);
}
// JWT 페이로드에 담긴 claims 조회
private Claims getClaimsFromToken(String token, SecretKey secretKey) {
try {
return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload();
} catch (ExpiredJwtException e) {
throw new ExpiredTokenException();
} catch (SignatureException e) {
throw new InvalidJwtSignatureException();
} catch (JwtException e) {
throw new InvalidTokenException(e.getMessage());
} catch (Exception e) {
throw new UnAuthenticatedException(e.getMe ssage());
}
}
참고 블로그
# 회원가입 Flow
<Front>
- 구글 로그인 버튼 클릭
- 구글 로그인 페이지로 redirect
- 구글 로그인
- 이메일
- 비밀번호
- 개인정보 제공 동의
<Back>
- OAuth2 API로부터 Auth Code 전달 받음.
- Auth Code를 리소스 서버로 전달.
- Auth Code를 가지고 Google API로 Access Token 요청
- Google Access Token을 가지고 회원 정보 요청
- GGB DB 조회로 회원가입 여부 확인. 비회원이면 400 + USER_NOT_FOUND + Google Access Token 반환
<Front>
- 400 + USER_NOT_FOUND + Google Access Token 응답을 받음
- 회원가입 페이지로 이동 및 정보 입력
- Back-end로 회원가입 정보와 Google 토큰 전달.
<Back>
- 회원가입 처리
- 로그인 처리
- Access/Refresh Token 발급
- Refresh Token 저장
- 200 + OK + Access/Refresh Toekn 리턴
# 로그인 Flow
<Front>
- 구글 로그인 버튼 클릭
- 구글 로그인
- 이메일
- 비밀번호
- 개인정보 제공 동의
<Back>
- OAuth2 API로부터 Auth Code 전달 받음.
- Auth Code를 리소스 서버로 전달.
- Auth Code를 가지고 Google API로 Access Token 요청
- Google Access Token을 가지고 회원 정보 요청
- GGB DB 조회로 회원가입 여부 확인.
- 회원 확인 후, Access/Refresh Token 발급
- Refresh Token 저장
- 200 + OK + Access/Refresh Toekn 리턴
# 토큰 재발급 flow
<Front>
- 소셜 로그인 요청
<Back>
- 200 OK + AccessToken + RefreshToken
<Front>
- 쿠키, 세션스토리지, 로컬스토리지 등에 Token 저장
- API 요청 시 Header Authorization에 Access Token 추가하여 요청
<Back>
- Header에서 Token 추출하여 검증
- 검증 오류 시 401 + UnAuthorized
<Front>
- Refresh Token으로 토큰 재발급 요청
<Back>
- Access Token + Refresh Token 재발급
- 검증 성공 시 정상 요청 수행
// Spring Security
implementation 'org.springframework.boot:spring-boot-starter-security'
// OAuth2
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
application.yml
security:
oauth2:
client:
registration:
google:
client-id: id
client-secret: password
scope: profile, email
SecurityConfig
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final CustomOAuth2UserService oAuth2UserService;
private final TokenService tokenService;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable) // csrf 비활성화
.cors(AbstractHttpConfigurer::disable) // cors 비활성화
.httpBasic(AbstractHttpConfigurer::disable) // 기본 인증 로그인 비활성화
.formLogin(AbstractHttpConfigurer::disable) // 기본 login form 비활성화
.logout(AbstractHttpConfigurer::disable) // 기본 logout 비활성화
.headers(c -> c
.frameOptions(
HeadersConfigurer.FrameOptionsConfig::sameOrigin)) // X-Frame-Options sameOrigin 제한
.sessionManagement(c -> c
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // 세션 비활성화
// 요청에 따른 리소스 접근 설정
.authorizeHttpRequests(
request -> request
.requestMatchers(
new AntPathRequestMatcher("/api/auth/**")
).authenticated()
.anyRequest().permitAll()
)
// OAuth2 설정
.oauth2Login(oauth -> oauth
.authorizationEndpoint(c -> c.baseUri("/oauth2/authorize"))
.userInfoEndpoint(c -> c.userService(oAuth2UserService))
.successHandler(new CustomOAuth2SuccessHandler(tokenService)) // 로그인 성공 시 처리
.failureHandler(new CustomOauth2FailHandler()) // 로그인 실패 시 처리
)
// 예외 핸들링
.exceptionHandling(ex -> ex
.authenticationEntryPoint(new CustomAuthenticationEntryPoint()) // 인증이 되지 않은 사용자가 인증이 필요한 URL에 접근 시 발생하는 예외 처리
.accessDeniedHandler(new CustomAccessDeniedHandler()) // 인가 처리 되지 않은 사용자가 어떤 권한이 필요한 URL에 접근 시 발생하는 예외 처리
)
// JWT 필터, 오류 핸들링 / 로깅 필터 추가
.addFilterBefore(new TokenAuthenticationFilter(tokenService),
UsernamePasswordAuthenticationFilter.class) // JWT 인증 필터
.addFilterBefore(new TokenExceptionHandlingFilter(),
TokenAuthenticationFilter.class) // 오류 핸들링
.addFilterBefore(new LogFilter(), TokenExceptionHandlingFilter.class) // 로깅 필터
;
return http.build();
}
}
CSRF
Cross-Site Request Forgery 로써, 인증된 사용자를 사칭하여 웹 서버에 원하지 않는 명령을 보내는 공격을 말합니다.
예를 들면, URL에 악성 매개변수를 포함하는 식으로 공격을 수행합니다.
이를 방지하기 위한 방법은 여러가지가 존재합니다.
JWT 사용 시, JWT 존재 여부로 CSRF 여부 확인이 가능하므로 Disabled() 설정해줍니다.
CORS
Cross-Origin Resource Sharing 로써, 브라우저가 자신의 출처가 아닌 다른 어떤 출처로 부터 자원을 로딩하는 것을 허용하도록 서버가 허가 해주는 HTTP 헤더 기반 메커니즘을 의미합니다.
CORS 는 교차 출처 리소스를 호스팅하는 서버가 실제 요청을 허가할 것인지 확인하기 위해 브라우저가 보내는 사전 요청 메커니즘에 의존합니다.
이 사전 요청에서 브라우저는 실제 요청에서 사용할 HTTP 메서드와 헤더들에 대한 정보가 표시된 헤더에 담아 보냅니다.
보안 상의 이유로 브라우저는 스크립트에서 시작한 교차 출처 HTTP 요청을 제한합니다. 이러한 경우, 웹 애플리케이션은 로드된 동일 출처 에서만 리소스 요청이 가능하며, 다른 출처의 응답에 올바른 CORS 헤더가 포함되어 있지 않는 한 그렇지 못하다는 것을 의미합니다.
즉, CORS 메커니즘은 브라우저와 서버 간의 안전한 교차 출처 요청 및 데이터 전송을 지원합니다.
CORS 실패는 오류가 발생되지만, 보안 상의 이유로 오류에 대한 세부 사항은 JavaScript로 제공되지 않습니다. 구체적인 내용은 브라우저의 콘솔에서 세부 사항을 확인해야 합니다.
아직, 프론트 서버를 거쳐 요청을 처리하는 상황이 없기 때문에, CORS 설정을 disabled 처리했습니다.
.authorizationEndpoint(c → c.baseUri(”/oauth2/authorize”))
Spring Security OAuth2 내에서 리소스 서버에 인가 코드 요청을 위한 URI로써, 클라이언트로부터 해당 URI로 요청이 들어오면, 인가코드 요청을 보낸 뒤, 클라이언트를 OAuth2 인증 페이지로 리다이렉션 시켜줍니다.
AuthenticationFailureHandler vs. AuthenticationEntryPoint
AuthenticationFailureHandler : 로그인에 실패 시 발생하는 예외 처리.
AuthenticationEntryPoint : 미인증 사용자가 인증이 필요한 리소스에 접근할 때 예외처리.
TokenAuthenticationFilter
@Slf4j
@RequiredArgsConstructor
public class TokenAuthenticationFilter extends OncePerRequestFilter {
private final TokenService tokenService;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {
// 1. 토큰 추출
String accessToken = tokenService.getToken(request);
// 2. 토큰 검증
if (StringUtils.hasText(accessToken) && !request.getRequestURI().contains("reissue")) {
tokenService.validateToken(accessToken);
Authentication auth = tokenService.getAuthentication(accessToken);
SecurityContextHolder.getContext().setAuthentication(auth);
}
filterChain.doFilter(request, response);
}
}
OncePerRequestFilter vs. GenericFilterBean
OncePerRequestFilter는 Spring에서 제공하는 특별한 필터로, HTTP 요청 당 한번만 실행되도록 보장해줍니다. 즉, 포워딩으로 인해 서블릿 요청이 바뀌어 전달되는 경우에도 한번의 필터링만 거치게 됩니다.
GenericFilterBean은 서블릿 요청이 올 때마다 필터링을 거치게 되므로, 불필요한 필터링이 발생하게 됩니다.
TokenExceptionHandlingFilter
@Slf4j
public class TokenExceptionHandlingFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {
try {
filterChain.doFilter(request, response);
} catch (UnAuthenticatedException e) {
writeResponse(response, HttpStatus.UNAUTHORIZED.value(),
ApiResponse.error(e.getCode(), e.getMessage()));
} catch (ForbiddenException e) {
writeResponse(response, HttpStatus.FORBIDDEN.value(),
ApiResponse.error(e.getCode(), e.getMessage()));
}
}
}
Filter에서 발생한 오류 처리
CustomOAuth2UserService
@Service
@RequiredArgsConstructor
public class CustomOAuth2UserService extends DefaultOAuth2UserService {
private final MemberService memberService;
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
Map<String, Object> attr = super.loadUser(userRequest).getAttributes();
String email = attr.get("email").toString();
Member member = memberService.findByEmail(email).orElseThrow(() -> {
String accessToken = userRequest.getAccessToken().getTokenValue();
return new NotJoinedUserException(accessToken);
});
String userNameAttributeName = userRequest.getClientRegistration()
.getProviderDetails()
.getUserInfoEndpoint()
.getUserNameAttributeName();
return new PrincipalDetails(member, attr, userNameAttributeName);
}
}
CustomOAuth2SuccessHandler
@Slf4j
@RequiredArgsConstructor
public class CustomOAuth2SuccessHandler implements AuthenticationSuccessHandler {
private final TokenService tokenService;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException {
Token token = tokenService.getToken(authentication);
tokenService.save(token);
writeResponse(
response,
HttpStatus.UNAUTHORIZED.value(),
ApiResponse.ok(
token
)
);
log.info("Successfully authenticated oauth2 token");
}
}
CustomOAuth2FailHandler
@Slf4j
public class CustomOauth2FailHandler implements AuthenticationFailureHandler {
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
AuthenticationException exception) throws IOException {
if (exception instanceof NotJoinedUserException e) {
writeResponse(
response,
HttpStatus.UNAUTHORIZED.value(),
ApiResponse.error(
e.getCode(),
e.getMessage(),
e.getAccessToken()
)
);
}
log.error("Custom Oauth2 Authentication Failed", exception);
}
}
MemberController
@RestController
@RequestMapping("/api/v1/users")
@RequiredArgsConstructor
@Slf4j
public class MemberController {
private final MemberService memberService;
private final TokenService tokenService;
@PostMapping("/signup")
public ApiResponse<Token> signup(@RequestBody MemberJoinRequest memberJoinRequest) {
Token token = memberService.join(memberJoinRequest);
return ApiResponse.ok(token);
}
...
}
MemberService
@Service
@RequiredArgsConstructor
@Slf4j
public class MemberService {
private final MemberRepository memberRepository;
private final TokenService tokenService;
private final AuthProvider authProvider;
public Token join(MemberJoinRequest request) {
// NOTE : 확장성 고려, OpenFeign 또는 추상화 도입 고민
String userInfoEndpointUri = "https://www.googleapis.com/oauth2/v3/userinfo";
HttpHeaders headers = new HttpHeaders();
headers.add("Authorization", "Bearer " + request.accessToken());
HttpEntity<String> entity = new HttpEntity<>(headers);
ResponseEntity<String> response = new RestTemplate().exchange(userInfoEndpointUri,
HttpMethod.GET, entity,
String.class);
log.info("Google Response >> {}", response.getBody());
Member googleMember = new Gson().fromJson(response.getBody(), Member.class);
Member member = Member.builder()
.email(googleMember.getEmail())
.profile(googleMember.getProfile())
.nickname(request.nickname())
.address(request.address())
.phone(request.phone())
.gender(request.gender())
.build();
memberRepository.save(member);
return saveToken(member);
}
private Token saveToken(Member member) {
Token token = tokenService.getToken(authProvider.getAuthentication(member));
tokenService.save(token);
return token;
}
}
TokenService
@Service
@RequiredArgsConstructor
public class TokenService {
private final TokenProvider tokenProvider;
private final TokenRepository tokenRepository;
// AccessToken 생성 & RefreshToken 생성 및 저장
public TokenDto getToken(Authentication authentication) {
String accessToken = tokenProvider.createAccessToken(authentication);
String refreshToken = tokenProvider.createRefreshToken(authentication);
return TokenDto.builder()
.accessToken(accessToken)
.refreshToken(refreshToken)
.build();
}
...
}
MemberController
@PostMapping("/reissue")
public ApiResponse<Token> reissue(@RequestBody TokenReissueRequest tokenReissueRequest) {
Token token = tokenService.reissueAccessToken(tokenReissueRequest);
return ApiResponse.ok(token);
}
TokenService
// accessToken 재발급
// NOTE : AccessToken create 시, 함께 생성된 RefreshToken 인지 여부를 확인함으로써, 보안 강화.
public Token reissueAccessToken(TokenReissueRequest tokenReissueRequest) {
String accessToken = tokenReissueRequest.accessToken();
String refreshToken = tokenReissueRequest.refreshToken();
if (!StringUtils.hasText(accessToken) || !StringUtils.hasText(refreshToken)) {
throw new NotExistsTokenException();
}
Token token = tokenRepository.findByAccessTokenAndRefreshToken(accessToken, refreshToken);
if (token == null) {
throw new NotFoundTokenException();
}
String reissuedAccessToken = tokenProvider.createAccessToken(refreshToken);
String reissuedRefreshToken = tokenProvider.createRefreshToken(reissuedAccessToken);
token.updateAccessToken(reissuedAccessToken, reissuedRefreshToken);
tokenRepository.update(token);
return token;
}
개요
원인
조치
추가
개요 : h2-console에 접속이 되지 않는 현상 발생.
원인 : security와 충돌 예상
host:port/h2-console 경로로 접속이 가능하며, 이를 통해 다양한 리소스에 접근이 가능하다.조치 :
@Bean
public WebSecurityCustomizer webSecurityCustomizer() {
return web -> web.ignoring()
.requestMatchers("/h2-console/*");
}
이렇게 Spring Security + Spring Security OAuth2 + JWT 를 활용한 회원가입 및 로그인 기능 구현이 끝났습니다. 리소스 서버에 요청을 보내고 회원정보를 가져오고 이를 서버 내에서 처리하는 전 과정에 대해서 학습하고 직접 구현해보니, Spring Security OAuth2 내 인증/인가가 어떻게 수행되는지에 대해서 알 수 있었습니다.
다음 포스팅에서는 직접 구현한 코드를 리팩토링하며 관련 내용들에 대해 포스팅하겠습니다.