티스토리 뷰
OAuth
- 사용자의 정보를 가지고 있는 제3의 서비스로부터 접근 권한을 위임 받아서 웹 사이트의 자원에 접근하는 방식
- 사용자의 정보를 저장하고 관리해야 하는 부담을 줄일 수 있다.
- 사용자도 자신의 개인정보를 신뢰할 수 없는 타인에게 제공하는 단점을 해결한다.
- OAuth 제공자는 네이버, 카카오, 구글 등이 있다.
JWT
- Header, Payload, Signature로 구성된 암호화된 토큰
- session 기반 인증 방식과 달리 stateless한 구현이 가능하다.
로그인 흐름
- Resource Owner(사용자)가 Client(서버) 자원에 접근하려 하면 Resource Server(OAuth 제공자)의 로그인 창을 호출하여 인증을 요구한다.
- 로그인을 성공하면 Resource Server에 로그인 API를 만들때 등록한 Redirect URI(http://{Client IP 주소}/login/oauth2/code/{registrationId}?{code}) 경로로 요청이 들어온다.
- Client는 {code} 값을 이용해서 다시 Resource Server로 access 토큰을 요청한다.
- Client는 access 토큰으로 다시 Resource Server로 사용자 정보를 요청한다.
- 사용자 정보를 이용해서 우리 서비스의 회원인지 아닌지 판단하고 웹사이트의 사용자이면 JWT로 access 토큰과 refresh 토큰을 만들어서 반환하고 웹사이트의 사용자가 아니면 회원가입에 필요한 사용자 기본 정보를 반환한다.
- 만약 JWT로 만들어서 반환한 access 토큰이 만료된 경우 refresh 토큰으로 Resource Server에게 access 토큰 재발행을 요청한다.
SecurityConfig.java
@Configuration
@RequiredArgsConstructor
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf().disable()
.headers().frameOptions().disable()
.and()
.cors()
.and()
.sessionManagement()//세션 정책 설정
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
.antMatchers("/login/**", "/user", "/oauth2/**", "/auth/**", "/h2-console/**").permitAll()
.anyRequest().authenticated()
.and()
.oauth2Login()
.redirectionEndpoint()
.baseUri("/oauth2/code/*")
;
return http.build();
}
}
- csrf().disable() : REST API는 stateless 하기 때문에 Cross-site request forgery 공격을 막는 코드가 필요없다.
- headers().frameOptions().disable() : h2-console 화면을 사용하기 위해서 비활성화
- cors() : 프론트엔드 서버와 통신을 위해 설정
- sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) : 스프링 시큐리티가 세션을 만들지도 기존 것을 사용하지도 않음.
- authorizeRequests().antMatchers("/login/**", "/user", "/oauth2/**", "/auth/**", "/h2-console/**").permitAll() : 인증 절차 없이 허용할 URI
- anyRequest().authenticated() : 위에서 허용한 URI를 제외한 모든 요청은 인증을 완료해야 접근 가능
- oauth2Login().redirectionEndpoint().baseUri("/oauth2/code/*") : oauth 인증 후 리디렉션할 URI 지정
SecurityFilter.java
@Slf4j
@Component
@RequiredArgsConstructor
public class SecurityFilter extends OncePerRequestFilter {
private final SecurityUtil securityUtil;
private final UserRepository userRepository;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) {
log.debug("**** SECURITY FILTER START");
try {
String authorizationHeader = request.getHeader("Authorization");
String token;
String userId;
String provider;
if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) {
token = authorizationHeader.substring(7);
if (securityUtil.isExpiration(token)) { // 만료되었는지 체크
throw new BadRequestException("EXPIRED_ACCESS_TOKEN");
}
userId = (String) securityUtil.get(token).get("userId");
provider = (String) securityUtil.get(token).get("provider");
if(!userRepository.existsByIdAndAuthProvider(userId, AuthProvider.findByCode(provider))){
throw new BadRequestException("CANNOT_FOUND_USER");
}
}
filterChain.doFilter(request, response);
} catch (BadRequestException e) {
if (e.getMessage().equalsIgnoreCase("EXPIRED_ACCESS_TOKEN")) {
writeErrorLogs("EXPIRED_ACCESS_TOKEN", e.getMessage(), e.getStackTrace());
JSONObject jsonObject = createJsonError(String.valueOf(UNAUTHORIZED.value()), e.getMessage());
setJsonResponse(response, UNAUTHORIZED, jsonObject.toString());
} else if (e.getMessage().equalsIgnoreCase("CANNOT_FOUND_USER")) {
writeErrorLogs("CANNOT_FOUND_USER", e.getMessage(), e.getStackTrace());
JSONObject jsonObject = createJsonError(String.valueOf(UNAUTHORIZED.value()), e.getMessage());
setJsonResponse(response, UNAUTHORIZED, jsonObject.toString());
}
} catch (Exception e) {
writeErrorLogs("Exception", e.getMessage(), e.getStackTrace());
if (response.getStatus() == HttpStatus.OK.value()) {
response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value());
}
} finally {
log.debug("**** SECURITY FILTER FINISH");
}
}
private JSONObject createJsonError(String errorCode, String errorMessage) {
JSONObject jsonObject = new JSONObject();
try {
jsonObject.put("error_code", errorCode);
jsonObject.put("error_message", errorMessage);
} catch (JSONException ex) {
writeErrorLogs("JSONException", ex.getMessage(), ex.getStackTrace());
}
return jsonObject;
}
private void setJsonResponse(HttpServletResponse response, HttpStatus httpStatus, String jsonValue) {
response.setStatus(httpStatus.value());
response.setContentType(APPLICATION_JSON_VALUE);
try {
response.getWriter().write(jsonValue);
response.getWriter().flush();
response.getWriter().close();
} catch (IOException ex) {
writeErrorLogs("IOException", ex.getMessage(), ex.getStackTrace());
}
}
private void writeErrorLogs(String exception, String message, StackTraceElement[] stackTraceElements) {
log.error("**** " + exception + " ****");
log.error("**** error message : " + message);
log.error("**** stack trace : " + Arrays.toString(stackTraceElements));
}
}
- Filter에서 Bearer에 입력된 JWT 토큰의 유효성을 검사한다.
KakaoRequestService.java
@Service
@RequiredArgsConstructor
public class KakaoRequestService implements RequestService {
private final UserRepository userRepository;
private final SecurityUtil securityUtil;
private final WebClient webClient;
@Value("${spring.security.oauth2.client.registration.kakao.authorization-grant-type}")
private String GRANT_TYPE;
@Value("${spring.security.oauth2.client.registration.kakao.client-id}")
private String CLIENT_ID;
@Value("${spring.security.oauth2.client.registration.kakao.redirect-uri}")
private String REDIRECT_URI;
@Value("${spring.security.oauth2.client.provider.kakao.token_uri}")
private String TOKEN_URI;
@Override
public SignInResponse redirect(TokenRequest tokenRequest) {
TokenResponse tokenResponse = getToken(tokenRequest);
KakaoUserInfo kakaoUserInfo = getUserInfo(tokenResponse.getAccessToken());
if(userRepository.existsById(String.valueOf(kakaoUserInfo.getId()))){
String accessToken = securityUtil.createAccessToken(
String.valueOf(kakaoUserInfo.getId()), AuthProvider.KAKAO, tokenResponse.getAccessToken());
String refreshToken = securityUtil.createRefreshToken(
String.valueOf(kakaoUserInfo.getId()), AuthProvider.KAKAO, tokenResponse.getRefreshToken());
return SignInResponse.builder()
.authProvider(AuthProvider.KAKAO)
.kakaoUserInfo(null)
.accessToken(accessToken)
.refreshToken(refreshToken)
.build();
} else {
return SignInResponse.builder()
.authProvider(AuthProvider.KAKAO)
.kakaoUserInfo(kakaoUserInfo)
.build();
}
}
@Override
public TokenResponse getToken(TokenRequest tokenRequest) {
MultiValueMap<String, String> formData = new LinkedMultiValueMap<>();
formData.add("grant_type", GRANT_TYPE);
formData.add("redirect_uri", REDIRECT_URI);
formData.add("client_id", CLIENT_ID);
formData.add("code", tokenRequest.getCode());
return webClient.mutate()
.baseUrl(TOKEN_URI)
.build()
.post()
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.body(BodyInserters.fromFormData(formData))
.retrieve()
.bodyToMono(TokenResponse.class)
.block();
}
@Override
public KakaoUserInfo getUserInfo(String accessToken) {
return webClient.mutate()
.baseUrl("https://kapi.kakao.com")
.build()
.get()
.uri("/v2/user/me")
.headers(h -> h.setBearerAuth(accessToken))
.retrieve()
.bodyToMono(KakaoUserInfo.class)
.block();
}
@Override
public TokenResponse getRefreshToken(String provider, String refreshToken) {
MultiValueMap<String, String> formData = new LinkedMultiValueMap<>();
formData.add("grant_type", "refresh_token");
formData.add("client_id", CLIENT_ID);
formData.add("refresh_token", refreshToken);
return webClient.mutate()
.baseUrl("https://kauth.kakao.com")
.build()
.post()
.uri("/oauth/token")
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.body(BodyInserters.fromFormData(formData))
.retrieve()
.bodyToMono(TokenResponse.class)
.block();
}
}
- code값을 가지고 oauth 공급자에게 access 토큰을 요청한다.
- access 토큰을 가지고 사용자 정보를 oauth 공급자에게 요청한다
application-oauth2.yml
spring:
config:
activate:
on-profile: local
security:
oauth2:
client:
registration:
google:
client-id: {google 로그인 api를 만들때 발급 받은 client-id}
client-secret: {google 로그인 api를 만들때 발급 받은 client-secret}
redirect-uri: "http://localhost:8081/login/oauth2/code/google"
authorization-grant-type: authorization_code
scope: profile, email
kakao:
client-id: {kakao 로그인 api를 만들때 발급 받은 client-id}
redirect-uri: "http://localhost:8081/login/oauth2/code/kakao"
client-authentication-method: POST
authorization-grant-type: authorization_code
scope: profile_nickname, profile_image, account_email
client-name: Kakao
naver:
client-id: {naver 로그인 api를 만들때 발급 받은 client-id}
client-secret: {naver 로그인 api를 만들때 발급 받은 client-id}
redirect-uri: "http://localhost:8081/login/oauth2/code/naver"
authorization-grant-type: authorization_code
scope: name, email, profile_image
client-name: Naver
provider:
google:
authorization-uri: https://accounts.google.com/o/oauth2/v2/auth?prompt=consent&access_type=offline
token_uri: https://oauth2.googleapis.com/token
user-info-uri: https://www.googleapis.com/oauth2/v2/userinfo
kakao:
authorization_uri: https://kauth.kakao.com/oauth/authorize
token_uri: https://kauth.kakao.com/oauth/token
user-info-uri: https://kapi.kakao.com/v2/user/me
user_name_attribute: id
naver:
authorization_uri: https://nid.naver.com/oauth2.0/authorize
token_uri: https://nid.naver.com/oauth2.0/token
user-info-uri: https://openapi.naver.com/v1/nid/me
user_name_attribute: response
- 로그인 API를 만들면 제공되는 정보들은 공유하면 안되기 때문에 src/main/resources 하위 경로에 application-oauth2.yml 작성해서 각자 client-id와 client-secret 정보를 입력한다.
전체 소스
https://github.com/seogineer/demo-oauth-springboot
참고
https://www.rfc-editor.org/rfc/rfc6749#page-7
https://tecoble.techcourse.co.kr/post/2021-07-10-understanding-oauth/
https://www.baeldung.com/spring-security-5-oauth2-login
'Framework > Spring' 카테고리의 다른 글
CORS 부터 Mixed Content 까지 문제 해결 (0) | 2025.01.02 |
---|---|
Spring Framework에서 @Transactional이 동작하지 않는 문제 (0) | 2024.10.10 |
MessageSource 다국어 처리 (2) | 2022.06.15 |
"Web server failed to start. Port 8080 was already in use." 에러 (0) | 2021.02.05 |
브라우저 net::ERR_CONTENT_LENGTH_MISMATCH 200 (0) | 2021.02.03 |
댓글