인증 / 인가
인증관련 아키텍처(Form login)
Servlet Filter가 요청을 DelegatingFilterProxy로 전달 -> DelegatingFilterProxy는 해당 요청을 Spring Container에 생성된 Filter를 구현한 스프링 빈에 위임(=FilterChain Proxy)
=> DelegatingFilterProxy는 FilterChainProxy에게 요청을 위임
[AuthenticationManager]
[AuthenticationProvider]
[Authentication]
[ProviderManager]
[SecurityContextHolder]
[SecurityContext]
미검증 Authentication
authentication 함수
에 작성sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) 옵션
클라이언트가 form인증방식으로 인증시도 시 UsernamePasswordAuthenticationFilter가 인증 처리를 하게 되고 앞의 방식대로 Security Context에 Authentication 객체를 저장하고 인증처리를 하게 된다.
하지만 해당 설정을 하게 될 경우 SecurityContextPersistenceFilter가 SecurityContext객체를 세션에 저장하지 않게 되기 때문에 클라이언트가 자원을 요청할 때마다 항상 새로운 SecurityContext객체를 생성하게 된다. 따라서 인증 성공 당시 SecurityContext에 저장했던 Authentication 객체를 더이상 참조할 수 없게 되어 매번 인증을 받아야 하는 상태가 된다.
이를 위해서는 JWT 토큰 내부의 사용자 정보를 JWT 필터에서 SecurityContext에 저장함으로써 인증처리를 해야한다.
OAUTH2.0
Resource Owner
개인 정보의 소유자(유저)
Client
제 3의 서비스로부터 인증을 받고자 하는 서버(내가 개발한 서버)
Resource Server
개인 정보를 저장하고 있는 서버(Google, Kakao) -> 자원을 제공해준다.
Authorization Server
OAuth를 통해 인증, 인가를 제공해주는 서버 -> 토큰을 발급해준다.
=> Authorization Server을 통해 받은 토큰을 이용하여 Resource Server로부터 자원을 제공받는다.
프론트엔드가 웹이냐 안드로이드냐에 따라 0Auth2.0 프로토콜을 이용하는 방식에는 차이가 있다.
총 세가지 방법이 존재하는 데,
- [WEB] 백엔드에서 Authentication Server와 Resource Server 모두 통신하여 프론트에게 JWT 토큰만 던져주는 방식
- [WEB] Authentication server에서 프론트에게 바로 access token을 주는 것이 아니라 Authorization code만 넘겨주고 해당 code를 받은 백엔드가 access/refresh token을 받는 방식
- [ANDROID] 프론트가 Access Token까지 받은 뒤 백엔드에서 해당 토큰을 기반으로 Resource 서버와 통신하는 방식
[특징]
.and()
.formLogin().disable()
.oauth2Login()
.authorizationEndpoint() // front -> back으로 요청 보내는 URL
.baseUri("/oauth2/authorize") // ex) /oauth2/authorize/google
.authorizationRequestRepository(cookieAuthorizationRequestRepository)
.and()
.redirectionEndpoint() //Authorization code와 함께 리다이렉트할 URL ex) /login/oauth2/code/google
.baseUri("/login/oauth2/code/*")
.and()
.userInfoEndpoint() // Provider로부터 획득한 유저정보를 다룰 service class를 지정
.userService(customOAuth2UserService)
.and()
.successHandler(successHandler) // OAuth2 로그인 성공 시 호출되는 handler
.failureHandler(failureHandler);
http.exceptionHandling()
.authenticationEntryPoint(jwtAuthenticationEntryPoint)// 인증 과정에서 생길 exception 처리
.accessDeniedHandler(jwtAccessDeniedHandler); // 인가 과정에서 생길 Exception 처리
http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
// UsernamePasswordFilter에서 클라이언트가 요청한 리소스의 접근 권한이 없을 때 막는 역할을 하기 때문에 이 필터 전에 jwtAuthenticationFilter실행
(authorizationEndpoint로)
만 하면 백엔드에서 Authentication Server + Resource Server와의 통신 후 얻은 자원을 기반으로 JWT 토큰을 생성하여 프론트에게 던져주는 flowpackage com.example.Estate_Twin.auth.service;
import com.example.Estate_Twin.auth.dto.OAuth2UserInfo;
import com.example.Estate_Twin.exception.OAuthProcessingException;
import com.example.Estate_Twin.user.domain.entity.*;
import com.example.Estate_Twin.user.domain.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.oauth2.client.userinfo.*;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Service;
import java.util.Optional;
@RequiredArgsConstructor
@Service
public class CustomOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
private final UserRepository userRepository;
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
OAuth2UserService<OAuth2UserRequest, OAuth2User> delegate = new DefaultOAuth2UserService();
OAuth2User oAuth2User = delegate.loadUser(userRequest);
return process(userRequest,oAuth2User);
}
private OAuth2User process(OAuth2UserRequest userRequest, OAuth2User oAuth2User) {
String registrationId = userRequest.getClientRegistration().getRegistrationId().toUpperCase();
String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName();
OAuth2UserInfo attributes = OAuth2UserInfo.of(registrationId, userNameAttributeName, oAuth2User.getAttributes());
if(attributes.getEmail().isEmpty()) {
throw new OAuthProcessingException("Email not found from OAuth2 provider");
}
Optional<User> userOptional = userRepository.findByEmail(attributes.getEmail());
User user;
//이미 가입된 경우
if(userOptional.isPresent()){
user = userOptional.get();
if(AuthProvider.valueOf(registrationId) != user.getAuthProvider()) {
throw new OAuthProcessingException("Wrong Match Auth Provider");
}
} else {
//첫 로그인인 경우
user = createUser(attributes,AuthProvider.valueOf(registrationId));
}
return CustomUserDetails.create(user,oAuth2User.getAttributes());
}
private User createUser(OAuth2UserInfo oAuth2UserInfo, AuthProvider authProvider) {
User user = User.builder()
.email(oAuth2UserInfo.getEmail())
.authProvider(authProvider)
.role(Role.USER)
.name(oAuth2UserInfo.getName())
.build();
return userRepository.save(user);
}
}
로컬환경에서는 정상적으로 로그인 과정이 진행되는데 서버에 올린 후에는 자꾸 authorizationEndpoint로 GET API를 날릴 때 404 Error가 발생하였다.
같은 코드인데 도대체 왜그럴까 하면서 엄청나게 삽질을 했는데, 같은 소마 동기의 도움으로 Redirect URI가 로컬로 박혀있었기 때문이라는 것을 알게 되었다~..
계속 authorizationEndpoint가 왜 404가 뜰까라고 여기에만 꽂혀서 고민을 했었는데 개발자 도구로 보니 반환하는 과정에서 에러가 난걸 볼 수 있었다..
개발자 도구의 중요성에 대해 깨달음~ + 동기의 소중함ㅎㅎ
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authentication) throws IOException, ServletException {
if(response.isCommitted()) {
log.debug("Response has already been committed");
return;
}
Token token = tokenProvider.createToken(authentication);
writeTokenResponse(response,token);
}
//response에다가 token을 담아서 줌
private void writeTokenResponse(HttpServletResponse response, Token token) throws IOException{
response.setContentType("text/html;charset=UTF-8");
response.addHeader("Access",token.getAccessToken());
response.addHeader("Refresh",token.getRefreshToken());
response.setContentType("application/json;charset=UTF-8");
//응답 스트림에 텍스트를 기록하기 위함
PrintWriter out = response.getWriter();
//스트림에 텍스트를 기록
out.println(objectMapper.writeValueAsString(token));
out.flush();
}
해당 방식처럼 response에 token을 담아 주려고 했으나 해당 request는 client로부터 온 것이 아니라 security 내부 로직 상의 request였기 때문에 response로 반환한다고 해서 client에게 전달되지 않았다.
두번째로는
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authentication) throws IOException, ServletException {
String targetUrl = determineTargetUrl(request, response, authentication);
if (response.isCommitted()) {
logger.debug("Response has already been committed. Unable to redirect to " + targetUrl);
return;
}
clearAuthenticationAttributes(request, response);
getRedirectStrategy().sendRedirect(request, response, targetUrl);
}
protected String determineTargetUrl(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
Optional<String> redirectUri = CookieUtil.getCookie(request, REDIRECT_URI_PARAM_COOKIE_NAME)
.map(Cookie::getValue);
String targetUrl = "http://localhost:3000/oauth2/redirect";
Token token = tokenProvider.createToken(authentication);
return UriComponentsBuilder.fromUriString(targetUrl)
.queryParam("token", token)
.build().toUriString();
}
이처럼 웹사이트처럼 redirectURI를 설정하여 해당 URI의 Parameter로 전달하려 했으나 이와 같이 할 경우 안드로이드 앱에서 크롬창이 열리게 되고, 해당 창에서는 아무런 정보를 얻어올 수 없는 문제를 마주했다.
여러가지 시도 끝에 해당 방법은 안드로이드 개발자와 협업 시 사용할 수 없다는 것을 알게 되었고, 한 기능을 개발하기 전 로직을 회의하는 것에 대한 중요성에 대해 깨닫게 되었다.
이 로직을 이해하는 데 꽤나 오랜 시간이 걸렸고, 이렇게 아름다운 코드를 내 포트폴리오에 추가할 수 있을 거란 기쁨에 다른 방법을 시도해야 한다는 사실을 계속 외면해 왔다. 이러한 내 욕심 때문에 프론트 개발자는 이에 억지로라도 끼워 맞추기 위해 다양한 방법을 시도했고, 불필요한 시간낭비만 이어졌다.
따라서 아무리 오랜 시간을 걸려서 이해한 로직이라도 상황에 따라 맞지 않으면 유연하게 바꿀 수 있는 마음가짐을 가져야 한다는 것을 깨달았다. 공부한 것은 나중에 언제든지 활용할 기회가 온다고 생각한다. 또한 내 의견이 무조건적으로 맞다는 생각도 버려야 하며, 남의 의견도 수용할 수 있는 자세를 가져야 한다는 자기 반성을 하게 되었다.
프론트에서 Authorization Server로부터 발급받은 Authorization code를 백엔드에게 넘겨주면 이를 기반으로 백엔드에서 Authorization Server로부터 Access Token을 발급받고, 이를 기반으로 Resource Server로부터 유저 자원을 제공받는 방식이다.
해당 방식 장점은 Access Token 자체가 백엔드에만 존재하게 되므로 중간에 탈취당하는 것을 막을 수 있다.
=> 해당 방식은 프론트엔드 개발자가 인가 코드만 받아올 방법이 없다고 하여 적용하지 못하게 되었다.
@Tag(name = "User", description = "유저 API")
@RequiredArgsConstructor
@RestController
@RequestMapping("/api/user")
public class UserController {
private final UserService userService;
private final OAuthService oAuthService;
@Operation(summary = "login of user", description = "로그인")
@ApiResponses({@ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = Token.class)))})
@Parameters({@Parameter(name = "provider", description = "Name of provider", example = "kakao, naver, google")})
@GetMapping("/login/oauth/{provider}")
public ResponseEntity<Token> login(@PathVariable String provider, @RequestBody String code) { // 인가 코드
Token token = oAuthService.login(provider, code);
return ResponseEntity.status(HttpStatus.OK).body(token);
}
}
@Service
@Slf4j
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class OAuthService {
private final InMemoryClientRegistrationRepository inMemoryRepository;
private final UserRepository userRepository;
private final JwtTokenProvider tokenProvider;
@Transactional
public Token login(String providerName, String code) { //로그인 로직 모두 처리하는 메서드
ClientRegistration provider = inMemoryRepository.findByRegistrationId(providerName);
//kakao로부터 access, refresh토큰 받아옴
OAuth2AccessTokenResponse tokenResponse = getToken(code, provider);
//kakao로부터 유저정보 받아서 db에 저장
User user = getUserProfile(providerName, tokenResponse, provider);
//jwt token 발급
String accessToken = tokenProvider.createAccessToken(user);
String refreshToken = tokenProvider.createRefreshToken(user);
Token token = new Token(accessToken,refreshToken);
return token;
}
private MultiValueMap<String, String> tokenRequest(String code, ClientRegistration provider) {
MultiValueMap<String, String> formData = new LinkedMultiValueMap<>();
formData.add("code", code);
formData.add("grant_type", "authorization_code");
formData.add("redirect_uri", provider.getRedirectUri());
formData.add("client_secret", provider.getClientSecret());
formData.add("client_id",provider.getClientId());
return formData;
}
//kakao로부터 access token, refresh token 전달 받음
private OAuth2AccessTokenResponse getToken(String code, ClientRegistration provider) {
return WebClient.create()
.post()
.uri(provider.getProviderDetails().getTokenUri())
.headers(header -> {
header.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
header.setAcceptCharset(Collections.singletonList(StandardCharsets.UTF_8));
})
.bodyValue(tokenRequest(code, provider))
.retrieve()
.bodyToMono(OAuth2AccessTokenResponse.class)
.block();
}
//kakao로부터 User Resource를 전달받음
private Map<String, Object> getUserAttributes(ClientRegistration provider, OAuth2AccessTokenResponse tokenResponse) {
return WebClient.create()
.get()
.uri(provider.getProviderDetails().getUserInfoEndpoint().getUri())
.headers(header -> header.setBearerAuth(tokenResponse.getAccessToken().getTokenValue()))
.retrieve()
.bodyToMono(new ParameterizedTypeReference<Map<String, Object>>() {})
.block();
}
private User createUser(OAuth2UserInfo oAuth2UserInfo, AuthProvider authProvider) {
User user = User.builder()
.email(oAuth2UserInfo.getEmail())
.authProvider(authProvider)
.role(Role.USER)
.name(oAuth2UserInfo.getName())
.build();
return userRepository.save(user);
}
private User getUserProfile(String providerName, OAuth2AccessTokenResponse tokenResponse, ClientRegistration provider) {
Map<String, Object> userAttributes = getUserAttributes(provider, tokenResponse);
String userNameAttributeName = provider.getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName();
OAuth2UserInfo attributes = OAuth2UserInfo.of(providerName.toUpperCase(), userNameAttributeName, userAttributes);
if(attributes.getEmail().isEmpty()) {
throw new OAuthProcessingException("Email not found from OAuth2 provider");
}
Optional<User> userOptional = userRepository.findByEmail(attributes.getEmail());
User user;
//이미 가입된 경우
if(userOptional.isPresent()){
user = userOptional.get();
if(AuthProvider.valueOf(providerName) != user.getAuthProvider()) {
throw new OAuthProcessingException("Wrong Match Auth Provider");
}
} else {
//첫 로그인인 경우
user = createUser(attributes,AuthProvider.valueOf(providerName));
}
CustomUserDetails.create(user,userAttributes);
return user;
}
}
[Access Token]
[Refresh Token]
@Component
@Log4j2
@Configuration
@RequiredArgsConstructor
@PropertySource("classpath:application-oauth.properties")
public class JwtTokenProvider {
@Value("${app.auth.token.secret-key}")
private String SECRET_KEY;
private Long ACCESS_TOKEN_EXPIRE_LENGTH = 1000L*60*60000;
private Long REFRESH_TOKEN_EXPIRE_LENGTH = 1000L*60*60*24*7000;
private final CustomUserDetailService userDetailsService;
@PostConstruct
protected void init() {
this.SECRET_KEY = Base64.getEncoder().encodeToString(SECRET_KEY.getBytes());
}
public String createAccessToken(User user) {
return createToken(user, ACCESS_TOKEN_EXPIRE_LENGTH);
}
public String createRefreshToken(User user) {
return createToken(user, REFRESH_TOKEN_EXPIRE_LENGTH);
}
public String createToken(User user, long expireLength) {
Claims claims = Jwts.claims().setSubject(user.getEmail()); // payload부분에 들어갈 정보 조각
claims.put("username", user.getEmail());
Date now = new Date();
Date validity = new Date(now.getTime() + expireLength);
Key key = Keys.hmacShaKeyFor(SECRET_KEY.getBytes(StandardCharsets.UTF_8));
return Jwts.builder()
.setClaims(claims)
.setIssuedAt(now)
.setExpiration(validity)
.signWith(key,SignatureAlgorithm.HS512)
.compact();
}
public boolean validateToken(String token) { // 토큰 유효성 검사
try {
Jws<Claims> claimsJws = Jwts.parserBuilder()
.setSigningKey(SECRET_KEY.getBytes())
.build()
.parseClaimsJws(token);
return !claimsJws.getBody().getExpiration().before(new Date());
} catch (JwtException | IllegalArgumentException exception) {
return false;
}
}
public Authentication getAuthentication(String token) { // 토큰을 파싱하여 Authentication 객체 생성
UserDetails userDetails = userDetailsService.loadUserByUsername(this.getUserIdentifier(token));
return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
}
public String getUserIdentifier(String token){
return Jwts.parserBuilder()
.setSigningKey(SECRET_KEY.getBytes())
.build()
.parseClaimsJws(token).getBody().getSubject();
}
public String resolveToken(HttpServletRequest request) { // 헤더로 부터 토큰 얻어옴
return request.getHeader("X-AUTH-TOKEN");
}
}
@Component
@RequiredArgsConstructor
@Slf4j
public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Autowired
private JwtTokenProvider tokenProvider;
@Override
protected void doFilterInternal(HttpServletRequest servletRequest, HttpServletResponse servletResponse, FilterChain filterChain)
throws IOException, ServletException {
String token = tokenProvider.resolveToken(servletRequest);
if (tokenProvider.validateToken(token)) {
try {
setAuthToSecurityContextHolder(token);
} catch (Exception e) {
log.error("토큰에 해당하는 사용자가 없습니다.", e);
}
}
filterChain.doFilter(servletRequest, servletResponse);
}
private void setAuthToSecurityContextHolder(String token) {
Authentication auth = tokenProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(auth);
}
}
JWTAuthenticationFilter에서는 요청 Header에 담겨온 JWT Access Token을 validate하여 해당 토큰이 유효할 경우 토큰 내부의 사용자 정보를 기반으로 CustomUserDetails 객체를 생성하여 Security Context에 넣는다.
CustomUserDetails 객체는 DB에서 인증에 사용할 사용자 정보를 가진 UserDetails 클래스를 상속받았으며 CustomUserDetailService는 UserDetailsService를 상속받았다.
해당 클래스 내부에서 loadUserByUsername 메서드를 재정의 하였고, 필자는 identifier을 email로 설정하였기 때문에 해당 identifier을 기반으로 DB에서 사용자 정보를 가져온다.
이렇게 가져온 사용자 정보를 기반으로 UsernamePasswordAuthenticationToken을 발급받아 Authentication객체를 생성한다.
https://velog.io/@tmdgh0221/Spring-Security-%EC%99%80-OAuth-2.0-%EC%99%80-JWT-%EC%9D%98-%EC%BD%9C%EB%9D%BC%EB%B3%B4
https://europani.github.io/spring/2022/01/15/036-oauth2-jwt.html#h-oauth2--jwt-flow
https://velog.io/@do-hoon/Oauth-%EC%B9%B4%EC%B9%B4%EC%98%A4-%EB%A1%9C%EA%B7%B8%EC%9D%B8-Spring-Boot-JWT-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EA%B5%AC%ED%98%841
https://velog.io/@yoon_s_whan/Springboot-Oauth2-jwt-Kakao-Android