이 글은 OAuth2에 대해 어느정도 알고 있다는 전재 하에 작성되었습니다.
사전지식
참고로 Authorization Code 방식으로 진행하였습니다.
처음에는 프론트에서 처리할지 백엔드에서 처리할지에 대해 고민은 전혀 하지 않고 백엔드에서 다 처리하고 토큰(혹은 세션) 만 잘 전달해주면 되겠지 하고 그냥 백엔드에서 전부 처리하였다.
그러나 백엔드에서 모든 과정을 처리하려 하니 프론트엔드가 백엔드한테 끼워 맞춰야 하는 느낌을 강하게 받았고 결국 백엔드에서 모든 과정을 처리하는 방법은 좋은 방법이 아니라는 것을 깨닫게 되었다.
그래서 다시 원점으로 돌아가 OAuth2 인증 플로우 구성부터 시작하게 되었다.
프론트에서 OAuth2 인증을 모두 처리한다면 Client Id, Client Secret 가 둘다 노출이 될 가능성이 높다.
위의 방법은 고려조차 하지 않고 패스하였다.
처음에 진행했던 방법으로 백엔드에서 Spring Security와 OAuth2를 활용한 방식이다.
진행 Flow는 아래와 같다.
위의 과정은 단점이 존재한다.
로그인 성공 후, 백엔드 URL로 리다이렉트가 발생하는데 이로 인해 백엔드 URL이 노출되게 된다.
백엔드에서 로그인을 성공적으로 진행하고 프론트엔드로 리다이렉트 시킬 때 토큰(혹은 세션)을 URL 파라미터에 붙여서 보내야 한다. 위의 방식은 안전하지 못한 방식이라 생각했다.
결국 위의 방식을 철회하고 다른 방식을 찾아보게 되었다.
프론트엔드에서 인증 서버와의 통신으로 Auth Code 까지 받은 후, 이를 백엔드로 전달하여 백엔드에서 나머지 인증을 진행한 후, Response Header에 토큰(혹은 세션)을 붙여 이를 프론트엔드로 응답을 보내는 방법으로 진행되었다.
Flow는 아래와 같이 이루어졌다.
위와 같은 방식으로 2번에서의 단점을 없엘 수 있었다.
import { useEffect, useRef } from 'react'
export default function GoogleLogin({ onGoogleSignIn = () => {}, text = 'signin_with' }) {
const googleSignInButton = useRef(null)
useScript('https://accounts.google.com/gsi/client', () => {
window.google.accounts.id.initialize({
client_id: import.meta.env.VITE_CLIENT_ID,
callback: onGoogleSignIn,
})
window.google.accounts.id.renderButton(googleSignInButton.current, {
theme: 'filled_black',
size: 'large',
text,
width: '250',
})
})
return <div ref={googleSignInButton}></div>
}
const useScript = (url, onload) => {
useEffect(() => {
const script = document.createElement('script')
script.src = url
script.onload = onload
document.head.appendChild(script)
return () => {
document.head.removeChild(script)
}
}, [url, onload])
}
이때 Client_id는 .env에 저장한 후, .gitignore에 .env를 추가해야 한다.
import React, { useEffect } from 'react'
import { Link } from 'react-router-dom'
import GoogleLogin from './GoogleLogin'
import { setCookies, getCookies, setTokenAtCookies } from '../../../cookie/Cookie'
import axios from 'axios'
const Login = () => {
const onGoogleSignIn = async (res) => {
const { credential } = res
const result = await axios.post(
'http://localhost:8080/api/googleLogin',
JSON.stringify({ code: credential }),
{
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
},
)
const status = result.status
if (status !== 200) console.error('login failed')
console.log(result.headers)
const accessToken = result.headers.authorization
const refreshToken = result.headers.refresh
setTokenAtCookies(accessToken, refreshToken)
}
return (
<div className="bg-body-tertiary min-vh-100 d-flex flex-row align-items-center">
<CContainer>
<CRow className="justify-content-center">
<CCol md={8}>
<CCardGroup>
<CCard className="p-4">
<CCardBody>
<CForm>
<h1>Login</h1>
<p className="text-body-secondary">Sign In to your account</p>
<GoogleLogin onGoogleSignIn={onGoogleSignIn} text="Google login" />
</CForm>
</CCardBody>
</CCard>
</CCardGroup>
</CCol>
</CRow>
</CContainer>
</div>
)
}
export default Login
백엔드에서 OAuth2 인증 과정을 전부 진행했을 때와 달리 'org.springframework.boot:spring-boot-starter-oauth2-client'
dependency 대신 'com.google.api-client:google-api-client:2.4.0'
를 추가해야 했다. 또한 Security filter chain에 oauth2를 빼야 했다.
dependencies {
...
implementation 'com.google.api-client:google-api-client:2.4.0'
implementation 'com.auth0:java-jwt:4.4.0'
}
@RequiredArgsConstructor
@RestController
public class AuthController {
private final AuthService authService;
@PostMapping("/api/googleLogin")
public ResponseEntity<?> googleAuthLogin(@RequestBody IdToken request, HttpServletResponse response) {
TokenDto tokenDto = authService.login(request.code());
response.addHeader("Authorization", tokenDto.accessToken());
response.addHeader("Refresh", tokenDto.refreshToken());
return ResponseEntity.ok().build();
}
}
@Slf4j
@Transactional
@Service
public class AuthService {
private final GoogleIdTokenVerifier verifier;
private final JwtTokenizer jwtTokenizer;
private final UsersService usersService;
public AuthService( @Value("${spring.security.oauth2.client.registration.google.client-id}")String clientId, JwtTokenizer jwtTokenizer, UsersService usersService) {
this.jwtTokenizer = jwtTokenizer;
this.usersService = usersService;
NetHttpTransport transport = new NetHttpTransport();
JsonFactory jsonFactory = new GsonFactory();
this.verifier = new GoogleIdTokenVerifier.Builder(transport, jsonFactory)
.setAudience(Collections.singleton(clientId))
.build();
}
public TokenDto login(String code) {
try {
GoogleIdToken idToken = verifier.verify(code);
if(idToken == null) {
log.info("idToken is null");
return null;
}
GoogleIdToken.Payload payload = idToken.getPayload();
String email = payload.getEmail();
String firstName = (String) payload.get("given_name");
String lastName = (String) payload.get("family_name");
UsersRequestDto dto = UsersRequestDto.builder()
.email(email)
.name(firstName + lastName)
.provider("google")
.build();
Users users = usersService.findOrCreateUsers(dto);
String accessToken = "Bearer " + jwtTokenizer.createAccessToken(email);
String refreshToken = "Bearer " + jwtTokenizer.createRefreshToken(users.getId());
log.info("access token = {}", accessToken);
saveAuthentication(users);
return TokenDto.builder()
.accessToken(accessToken)
.refreshToken(refreshToken)
.build();
} catch (Exception e) {
log.error("error : ", e);
throw new AuthException(AuthErrorCode.LOGIN_FAILED);
}
}
public void saveAuthentication(Users users) {
UserDetails userDetails = new UserAccount(users);
List<GrantedAuthority> roles = new ArrayList<>();
roles.add(new SimpleGrantedAuthority(users.getRole()));
Authentication authentication =
new UsernamePasswordAuthenticationToken(userDetails, null, roles);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}
@Slf4j
@Component
@RequiredArgsConstructor
public class JwtTokenizer {
@Value("${jwt.secretKey}")
private String secretKey;
@Value("${jwt.access.expiration}")
private Long accessTokenExpirationPeriod;
@Value("${jwt.refresh.expiration}")
private Long refreshTokenExpirationPeriod;
@Value("${jwt.access.header}")
private String accessHeader;
@Value("${jwt.refresh.header}")
private String refreshHeader;
private Algorithm jwtAlgorithm;
private final Redis2Utils redisUtils;
@PostConstruct
public void setJwtAlgorithm() {
this.jwtAlgorithm = Algorithm.HMAC512(secretKey);
}
private static final String ACCESS_TOKEN_SUBJECT = "AccessToken";
private static final String REFRESH_TOKEN_SUBJECT = "RefreshToken";
private static final String EMAIL_CLAIM = "email";
private static final String BEARER = "Bearer ";
public String createAccessToken(String email) {
Date now = new Date();
return JWT.create()
.withSubject(ACCESS_TOKEN_SUBJECT) //jwt subject 지정
.withExpiresAt(new Date(now.getTime() + accessTokenExpirationPeriod)) //토큰 만료
.withClaim(EMAIL_CLAIM, email) //payload
.sign(jwtAlgorithm); //algorithm
}
public String createRefreshToken(Long userId) {
Date now = new Date();
// 기존 refresh token 삭제
redisUtils.deleteObject(userId);
String refreshToken = JWT.create()
.withSubject(REFRESH_TOKEN_SUBJECT)
.withExpiresAt(new Date(now.getTime() + refreshTokenExpirationPeriod))
.sign(jwtAlgorithm);
redisUtils.addObject(userId, refreshToken, refreshTokenExpirationPeriod);
return refreshToken;
}
public void sendAccessToken(HttpServletResponse response, String accessToken) {
response.setHeader(accessHeader, accessToken);
log.info("set accessToken to header");
}
public void sendRefreshToken(HttpServletResponse response, String refreshToken) {
response.setHeader(refreshHeader, refreshToken);
log.info("set refreshToken to header");
}
public Optional<String> extractEmail(HttpServletRequest request) {
Optional<String> accessToken = extractAccessToken(request);
try {
if (accessToken.isPresent()) {
String token = accessToken.get();
String optionalEmail = JWT.require(Algorithm.HMAC512(secretKey))
.build()
.verify(token)
.getClaim(EMAIL_CLAIM)
.asString();
return Optional.of(optionalEmail);
}
return Optional.empty();
} catch (Exception e) {
log.error("jwt not valid", e);
throw new IllegalArgumentException(e);
}
}
public String getEmail(String accessToken) {
DecodedJWT jwt = JWT.decode(accessToken);
return jwt.getClaim(EMAIL_CLAIM).asString();
}
public Optional<String> extractAccessToken(HttpServletRequest request) {
return Optional.ofNullable(request.getHeader(accessHeader))
.filter(at -> at.startsWith(BEARER))
.map(at -> at.replace(BEARER, ""));
}
public Optional<String> extractRefreshToken(HttpServletRequest request) {
return Optional.ofNullable(request.getHeader(refreshHeader))
.filter(at -> at.startsWith(BEARER))
.map(at -> at.replace(BEARER, ""));
}
public boolean isTokenValid(String token) {
try {
JWT.require(jwtAlgorithm)
.build()
.verify(token);
return true;
} catch (TokenExpiredException e) {
log.error("token expired : {}", e.getMessage());
return false;
} catch (Exception e) {
log.error("jwt error : {}", e.getMessage());
return false;
}
}
public boolean isTokenExpired(String token) {
DecodedJWT jwt = JWT.decode(token);
Date expDate = jwt.getExpiresAt();
Date now = new Date();
return now.after(expDate);
}
}
@RequiredArgsConstructor
@Configuration
public class SecurityConfig {
private final JwtAuthorizationProcessingFilter jwtAuthorizationProcessingFilter;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.httpBasic(AbstractHttpConfigurer::disable)
.formLogin(FormLoginConfigurer::disable)
.csrf(CsrfConfigurer::disable)
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers(new AntPathRequestMatcher("/h2/**")).permitAll()
.requestMatchers(new AntPathRequestMatcher("/api/googleLogin"), new AntPathRequestMatcher("/error"), new AntPathRequestMatcher("/index.html")).permitAll()
.requestMatchers(new AntPathRequestMatcher("/swagger-ui/**"), new AntPathRequestMatcher("/v3/**"), new AntPathRequestMatcher("/swagger-ui.html")).permitAll()
.requestMatchers(new AntPathRequestMatcher("/app/**"), new AntPathRequestMatcher("/topic/**"), new AntPathRequestMatcher("/web-socket-connection/**")).permitAll()
.anyRequest().authenticated());
http
.addFilterBefore(jwtAuthorizationProcessingFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(List.of("http://localhost:3000"));
configuration.setAllowedMethods(List.of("*"));
configuration.setAllowedHeaders(List.of("Authorization", "Refresh", "Content-type", "Origin", "Accept", "Access-Control-Allow-Origin", "Access-Control-Allow-Headers", "Access-Control-Allow-Methods"));
configuration.setExposedHeaders(List.of("Authorization", "Refresh"));
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
}
@Slf4j
@Component
@RequiredArgsConstructor
public class JwtAuthorizationProcessingFilter extends OncePerRequestFilter {
private static final String[] AUTHORIZATION_NOT_REQUIRED = new String[]{"/login", "/h2", "/web-socket-connection","/swagger-ui","/v3/api-docs","/topic/participant","/api/googleLogin"};
private final JwtTokenizer jwtTokenizer;
private final UsersRepository usersRepository;
private final Redis2Utils redisUtils;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
log.info("JwtAuthorizationProcessingFilter start");
log.info("request.getRequestURI() = {}", request.getRequestURI());
if (StringUtils.startsWithAny(request.getRequestURI(), AUTHORIZATION_NOT_REQUIRED)) {
filterChain.doFilter(request, response);
log.info("AUTHORIZATION_NOT_REQUIRED");
return;
}
//accessToken 확인
Optional<String> accessToken = jwtTokenizer.extractAccessToken(request);
if (accessToken.isPresent()) {
// 만약 accessToken 존재시 return;
log.info("accessToken exist");
boolean isAccessTokenValid = jwtTokenizer.isTokenValid(accessToken.get());
if (isAccessTokenValid) {
log.info("accessToken valid");
setAuthentication(accessToken.get());
} else {
if(jwtTokenizer.isTokenExpired(accessToken.get())) {
log.info("access token expired");
Optional<String> refreshToken = jwtTokenizer.extractRefreshToken(request);
//refresh token 존재시 accessToken reissue 후 return;
if (refreshToken.isPresent()) {
//refreshToken valid check
checkRefreshToken(response, refreshToken, accessToken);
} else {
log.info("refresh token not exist");
throw new AuthException(REFRESH_TOKEN_NOT_EXIST);
}
} else {
log.info("access token not valid");
throw new AuthException(JWT_NOT_VALID);
}
}
} else {
throw new AuthException(ACCESS_TOKEN_NOT_EXIST);
}
filterChain.doFilter(request, response);
}
private void checkRefreshToken(HttpServletResponse response, Optional<String> refreshToken, Optional<String> accessToken) {
if (!jwtTokenizer.isTokenValid(refreshToken.get())) {
log.info("refresh token not valid");
throw new AuthException(JWT_NOT_VALID);
}
Users users = getUsers(accessToken.get());
Optional<String> optionalRt = redisUtils.getObject(users.getId());
if(optionalRt.isPresent()) {
String rt = optionalRt.get();
if(!rt.equals(refreshToken.get())) {
throw new AuthException(JWT_NOT_VALID);
}
}
reIssueToken(users, response);
}
private void reIssueToken(Users users, HttpServletResponse response) {
log.info("checkRefreshTokenAndReIssueAccessToken start");
String token = jwtTokenizer.createRefreshToken(users.getId());
String accessToken = jwtTokenizer.createAccessToken(users.getEmail());
//securityContext에 저장
saveAuthentication(users);
//response에 저장
jwtTokenizer.sendAccessToken(response, accessToken);
jwtTokenizer.sendRefreshToken(response, token);
log.info("checkRefreshTokenAndReIssueAccessToken end");
}
private void setAuthentication(String accessToken) {
Users users = getUsers(accessToken);
saveAuthentication(users);
}
private Users getUsers(String accessToken) {
String email = jwtTokenizer.getEmail(accessToken);
return usersRepository.findByEmail(email)
.orElseThrow(() -> new UserException(USER_NOT_FOUND));
}
private void saveAuthentication(Users users) {
UserDetails userDetails = new UserAccount(users);
List<GrantedAuthority> roles = new ArrayList<>();
roles.add(new SimpleGrantedAuthority(users.getRole()));
Authentication authentication =
new UsernamePasswordAuthenticationToken(userDetails, null, roles);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
@Override
protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException {
log.info("should not filter = {}", request.getRequestURI());
boolean result = StringUtils.startsWithAny(request.getRequestURI(), AUTHORIZATION_NOT_REQUIRED);
log.info("should not filter = {}", result);
return result;
}
}
public record IdToken(String code) {
} // Authorization Code 전달
jwt 토큰 전달을 위해 사용
public record TokenDto(String accessToken, String refreshToken) {
@Builder
public TokenDto {
}
}
Reference
https://blog.thelumayi.com/92
https://hudi.blog/oauth-2.0/
https://ttl-blog.tistory.com/1434#%F0%9F%A7%90%20%EA%B5%AC%ED%98%84%ED%95%98%EB%8A%94%20%EB%B0%A9%EB%B2%95-1