이 글은 OAuth2에 대해 어느정도 알고 있다는 전재 하에 작성되었습니다.
사전지식
참고로 Authorization Code 방식으로 진행하였습니다.
처음에는 프론트에서 처리할지 백엔드에서 처리할지에 대해 고민은 전혀 하지 않고 백엔드에서 다 처리하고 토큰(혹은 세션) 만 잘 전달해주면 되겠지 하고 그냥 백엔드에서 전부 처리하였다.
그러나 백엔드에서 모든 과정을 처리하려 하니 프론트엔드로 JWT를 전달하는데 어려움을 겪었고 다른 방법을 찾기로 하였다.
말 그대로 프론트에서 모든 과정을 처리하고 사용자 정보만 백엔드로 넘기는 방법이다. (이후 백엔드에서 JWT를 만들어 프론트로 전달해주면 된다.)
필자는 백엔드이므로 패스하였다.
처음에 진행했던 방법으로 백엔드에서 Spring Security와 OAuth2를 활용한 방식이다.
진행 Flow는 아래와 같다.
위의 과정에서 백엔드 -> 프론트엔드로 JWT를 보낼 수 있는 적절한 방법을 찾지 못했고 결국 다른 방법으로 구현하기로 하였다.
+) 24/07/18
나중에 추가적으로 공부를 한 사실인데, 쿠키에 담으면 JWT전달을 할 수 있다.
문제는... CORS 인데 https + isSecure true, sameSite=NONE(혹은 프론트와 도메인이 같게하고 sameSite=STRICT로 하거나), httpOnly=true로 하면 된다. (프론트에서는 요청 보낼 때 isCredential=true 설정을 해주어야 한다.)
추가적으로 SecurityConfig의 CORS 설정시 OriginAllow에 정확한 도메인 지정과 AllowMethod에 정확한 메서드 지정(GET,POST,PATCH,OPTIONS...)이 필요하다
프론트엔드에서 인증 서버와의 통신으로 Auth Code 까지 받은 후, 이를 백엔드로 전달하여 백엔드에서 나머지 인증을 진행한 후, Response Header에 토큰(혹은 세션)을 붙여 이를 프론트엔드로 응답을 보내는 방법으로 진행되었다.
Flow는 아래와 같이 이루어졌다.
중간에 프론트에서 백엔드로 AuthCode를 전달하는 과정 때문에 프론트엔드와 백엔드가 책임을 나눠가지는 방식은 추천하는 방식은 아니지만 구현이 쉽다는 장점이 있어 이 방법을 채택하기로 하였다.
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
https://www.youtube.com/playlist?list=PLJkjrxxiBSFALedMwcqDw_BPaJ3qqbWeB