사용자 인증 정보 설정하는 법
- 승인된 자바스크립트 원본은 프론트엔드의 호스트를 기입하면된다.
- 승인된 리디렉션 URI는 백엔드 애플리케이션 서버의 URI를 입력해야 한다.
사용자 동의 화면
구글을 통해 받을 수 있는 scope
- scope중
profile
은 사용자 정의OAuth2SuccessHandler
클래스에서 애플리케이션 디버깅으로 어떤 정보가 있는지 확인할 수 있었다.
확인할 수 있는 내용은 성, 이름, 풀네임, 프로필사진 등이었다.
package com.codestates.stackoverflowbe.global.auth.config;
import com.codestates.stackoverflowbe.domain.account.service.AccountService;
import com.codestates.stackoverflowbe.global.auth.filter.JwtVerificationFilter;
import com.codestates.stackoverflowbe.global.auth.handler.*;
import com.codestates.stackoverflowbe.global.auth.filter.JwtAuthenticationFilter;
import com.codestates.stackoverflowbe.global.auth.jwt.JwtTokenizer;
import com.codestates.stackoverflowbe.global.auth.utils.CustomAuthorityUtils;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.client.web.OAuth2LoginAuthenticationFilter;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfiguration {
private final JwtTokenizer jwtTokenizer;
private final CustomAuthorityUtils authorityUtils;
private final AccountService accountService;
private final SecurityCorsConfig corsConfig;
@Bean
public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
httpSecurity
.headers().frameOptions().sameOrigin() // (해당 옵션 유효한 경우 h2사용가능) SOP 정책 유지, 다른 도메인에서 iframe 로드 방지
.and()
// .cors(Customizer.withDefaults()) //CORS 처리하는 가장 쉬운 방법인 CorsFilter 사용, CorsConfigurationSource Bean을 제공
// .cors(configuration -> configuration
// .configurationSource(request -> new CorsConfiguration().applyPermitDefaultValues()))
.csrf().disable()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 세션 정보 저장X
.and()
.formLogin().disable() // CSR 방식을 사용하기 때문에 formLogin 방식 사용하지 않음
.httpBasic().disable() // UsernamePasswordAuthenticationFilter, BasicAuthenticationFilter 등 비활성화
.exceptionHandling() // 예외처리 기능
.authenticationEntryPoint(new AccountAuthenticationEntryPoint()) // 인증 실패시 처리 (UserAuthenticationEntryPoint 동작)
.accessDeniedHandler(new AccountAccessDeniedHandler()) //인가 거부시 UserAccessDeniedHandler가 처리되도록 설계
.and()
.apply(new CustomFilterConfigurer()) // 커스터마이징한 필터 추가
.and() // 허용되는 HttpMethod와 역할 설정
.authorizeHttpRequests( authorize -> authorize
.antMatchers(HttpMethod.GET, "/v1/accounts/**").hasRole("ADMIN")
.antMatchers(HttpMethod.POST, "/v1/accounts/**").permitAll()
.anyRequest().permitAll()
)
//⭐ oauth2Login이 성공했을 때 동작하게끔 되는 OAuth2AccountSuccessHandler
.oauth2Login(
oauth2 -> oauth2
.successHandler(new OAuth2AccountSuccessHandler(jwtTokenizer, authorityUtils, accountService))
);
return httpSecurity.build();
}
... 중략...
public class CustomFilterConfigurer extends AbstractHttpConfigurer<CustomFilterConfigurer, HttpSecurity> {
@Override
public void configure(HttpSecurity builder) throws Exception {
// authenticationManager : 사용자가 로그인 요청시 입력한 아이디와 패스워드를 해당 객체로 전달하여 인증 수행하며, 결과에 따라 로직 처리
AuthenticationManager authenticationManager = builder.getSharedObject(AuthenticationManager.class); // AuthenticationManager 객체얻기
JwtAuthenticationFilter jwtAuthenticationFilter = new JwtAuthenticationFilter(authenticationManager, jwtTokenizer); // JwtAuthenticationFilter 객체 생성하며 DI하기
// AbstractAuthenticationProcessingFilter에서 상속받은 filterProcessurl을 설정 (설정하지 않으면 default 값인 /Login)
jwtAuthenticationFilter.setFilterProcessesUrl("/v1/accounts/authenticate");
jwtAuthenticationFilter.setAuthenticationSuccessHandler(new AccountAuthenticationSuccessHandler(accountService));
jwtAuthenticationFilter.setAuthenticationFailureHandler(new AccountAuthenticationFailureHandler());
JwtVerificationFilter jwtVerificationFilter = new JwtVerificationFilter(jwtTokenizer, authorityUtils);
// Spring Security FilterChain에 추가
builder
.addFilter(corsConfig.corsFilter())
.addFilter(jwtAuthenticationFilter)
//⭐ OAuth2LoginAuthenticationFilter : OAuth2.0 권한 부여 응답 처리 클래스 뒤에 jwtVerificationFilter 추가 (Oauth)
.addFilterAfter(jwtVerificationFilter, OAuth2LoginAuthenticationFilter.class);
}
}
}
package com.codestates.stackoverflowbe.global.auth.handler;
import com.codestates.stackoverflowbe.domain.account.dto.AccountDto;
import com.codestates.stackoverflowbe.domain.account.entity.Account;
import com.codestates.stackoverflowbe.domain.account.service.AccountService;
import com.codestates.stackoverflowbe.global.auth.jwt.JwtTokenizer;
import com.codestates.stackoverflowbe.global.auth.login.dto.LoginResponseDto;
import com.codestates.stackoverflowbe.global.auth.utils.CustomAuthorityUtils;
import com.google.gson.Gson;
import com.nimbusds.openid.connect.sdk.Display;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.DisplayName;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.util.UriComponentsBuilder;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Slf4j
//OAuth2 인증이 성공한 이후 동작 (SimpleUrlAuthenticationSuccessHandler : 인증 성공했을 때 URL 지정 등 역할 수행)
public class OAuth2AccountSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
private static String URL_S3_ENDPOINT = "http://se-sof.s3-website.ap-northeast-2.amazonaws.com";
private final JwtTokenizer jwtTokenizer;
private final CustomAuthorityUtils authorityUtils;
private final AccountService accountService;
public OAuth2AccountSuccessHandler(JwtTokenizer jwtTokenizer,
CustomAuthorityUtils authorityUtils,
AccountService accountService) {
this.jwtTokenizer = jwtTokenizer;
this.authorityUtils = authorityUtils;
this.accountService = accountService;
}
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
//OAuth2인증이 성공
log.info("# OAuth2AccountSuccessHandler success!");
OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal();
String email = (String) oAuth2User.getAttributes().get("email");
String name = (String) oAuth2User.getAttributes().get("name");
System.out.println("name: " + name);
List<String> authorities = authorityUtils.createRoles(email);
saveAccount(email, name); // email을 DB에 저장하여 관리하며 매핑하기
redirect(request, response, email, authorities);
// String profile = (String) oAuth2User.getAttributes().get("profile");
// Account account = buildOAuth2Account(email, profile);
// Account saveAccount = accountService.createAccountOAuth2(account);
}
private void saveAccount(String email, String displayName) {
AccountDto.Post accountPostDto = new AccountDto.Post(email);
accountPostDto.setDisplayName(displayName);
//OAuth 전용 DB 저장 로직
accountService.createAccountOAuth2(accountPostDto);
}
private void redirect(HttpServletRequest request, HttpServletResponse response,
String username, List<String> authorities) throws IOException {
// accessToken과 refreshToken 생성
String accessToken = delegateAccessToken(username, authorities);
String refreshToken = delegateRefreshToken(username);
// username(email)로 계정 찾아와서 Json 직렬화 이후 응답객체의 body에 입력하기
Account account = accountService.findByEmail(username);
String displayName = account.getDisplayName();
//FE 애플리케이션 쪽의 URI 생성.
String uri = createURI(request, accessToken, refreshToken).toString();
LoginResponseDto loginResponseDto = new LoginResponseDto(displayName);
Gson gson = new Gson();
String result = gson.toJson(loginResponseDto);
response.setContentType("application/json");
response.setCharacterEncoding("UTF-8");
response.getWriter().write(result);
// response.setHeader("displayName", displayName);
//SimpleUrlAuthenticationSuccessHandler에서 제공하는 sendRedirect() 메서드를 이용해 Frontend 애플리케이션 쪽으로 리다이렉트
getRedirectStrategy().sendRedirect(request, response, uri);
}
private String delegateAccessToken(String username, List<String> authorities) {
Map<String, Object> claims = new HashMap<>();
claims.put("username", username);
claims.put("roles", authorities);
String subject = username;
Date expiration = jwtTokenizer.getTokenExpiration(jwtTokenizer.getAccessTokenExpirationMinutes());
String base64EncodedSecretKey = jwtTokenizer.encodeBase64SecretKey(jwtTokenizer.getSecretKey());
String accessToken = jwtTokenizer.generateAccessToken(claims, subject, expiration, base64EncodedSecretKey);
return accessToken;
}
private String delegateRefreshToken(String username) {
String subject = username;
Date expiration = jwtTokenizer.getTokenExpiration(jwtTokenizer.getRefreshTokenExpirationMinutes());
String base64EncodedSecretKey = jwtTokenizer.encodeBase64SecretKey(jwtTokenizer.getSecretKey());
String refreshToken = jwtTokenizer.generateRefreshToken(subject, expiration, base64EncodedSecretKey);
return refreshToken;
}
private Object createURI(HttpServletRequest request, String accessToken, String refreshToken) {
// HTTP 요청의 쿼리 파라미터나 헤더를 구성하기 위한 Map
MultiValueMap<String, String> queryParams = new LinkedMultiValueMap<>();
queryParams.add("access_token", accessToken);
queryParams.add("refresh_token", refreshToken);
//⭐이 uri는 프론트엔드에서 해당 액세스토큰과 리프레시 토큰을 쿼리파라미터로 받아 저장할 수 있도록 구성된 페이지를 목적으로 한다.
//http://localhost/receive-token.html?access_token=XXX&refresh_token=YYY 형식으로 받도록 함.
return UriComponentsBuilder
.newInstance()
.scheme("http")
.host("se-sof.s3-website.ap-northeast-2.amazonaws.com") //"http://seveneleven-stackoverflow-s3.s3-website.ap-northeast-2.amazonaws.com"
.path("/login")
.queryParams(queryParams)
.build()
.toUri();
}
}
response 객체에 유저 정보 중 displayName을 loginResponseDto에 그대로 담아 주려고 했지만, setHeader
, getWriter().write()
등 다양한 방법으로 시도해보았으나, 프론트 쪽에서 그런 방식(응답 바디에 담아주거나 헤더로 보내주는 방식)으로는 받을 수 없다는 답변이 있어 다른 방법을 고심하게 되었다.
createURI
메서드에서 URI를 구성할 때 쿼리파라미터로 access_token
이나 refresh_token
을 쿼리파라미터로 넘기려던 것에 착안하여 displayName을 단독으로 넘기려고 하였으나 이 역시 불가능했다.
클라이언트에서 이유를 알 수 없는 백색 화면이 출력되며 토큰과 displayName 저장이 정상적으로 이루어지지 않았다.
해결책 : 액세스 토큰과 리프레시 토큰을 받게되면 SecurityContextHolder에 인증된 토큰(Claims)이 저장되게 된다. 이점에 착안하여 인증된 사용자 정보로부터 정보를 받는 또다른 api를 호출하도록 하였다.
package com.codestates.stackoverflowbe.global.auth.controller;
import com.codestates.stackoverflowbe.domain.account.entity.Account;
import com.codestates.stackoverflowbe.domain.account.service.AccountService;
import com.codestates.stackoverflowbe.global.auth.login.dto.LoginDto;
import com.codestates.stackoverflowbe.global.auth.login.dto.LoginResponseDto;
import com.codestates.stackoverflowbe.global.exception.BusinessLogicException;
import com.codestates.stackoverflowbe.global.exception.ExceptionCode;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.web.bind.annotation.*;
import javax.validation.constraints.Positive;
import java.util.Map;
import java.util.Optional;
@RestController
@RequestMapping("/v1/auth")
@Tag(name = "Auth", description = "인증 기능")
public class AuthController {
private final AccountService accountService;
public AuthController(AccountService accountService) {
this.accountService = accountService;
}
@GetMapping("/oauth")
public ResponseEntity<LoginResponseDto> getOAuth2UserDisplayName() {
// 시큐리티 컨텍스트 홀더에 저장되어 있는 인증 정보 가져오기
Authentication authentication =
Optional.ofNullable(SecurityContextHolder.getContext().getAuthentication()).orElseThrow(()
-> new BusinessLogicException(ExceptionCode.NOT_AUTHENTICATED));
// Map<String, Object> claims = (Map<String, Object>) authentication.getPrincipal();
Account account = accountService.findByEmail((String) authentication.getPrincipal());
LoginResponseDto loginResponseDto = new LoginResponseDto(account.getDisplayName());
return ResponseEntity.ok(loginResponseDto);
}
}
Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal()
)를 QuestionController
, AnswerController
등에서 사용하고 있었기 때문에 principal의 내용을 이메일(String) -> 객체(Map<String, Object> claims
)로 리팩토링 하지 못한 것이 아쉬웠다.getPrincipal()
의 정보를 최대한 활용하고자 하는 방향은 좋았다. SpringSecurity에 대한 기본적인 이해를 바탕으로 인증 흐름의 순서를 짚어가며 AccountDetails
를 구성할 때 생성자로 account.getDisplayName()
를 추가로 넘겨주었고, 인가를 담당하는 JwtVerificationFilter
필터의 setAuthenticationToContext
메서드에서, Authentication authentication = new UsernamePasswordAuthenticationToken(principal, null, authorities);
으로 인증된 토큰을 만들어 줄때 principal이 객체(Map<String, Object> claims
)가 되도록 설계 할 수 있었고, private Object createURI(HttpServletRequest request, String accessToken, String refreshToken) {
// HTTP 요청의 쿼리 파라미터나 헤더를 구성하기 위한 Map
MultiValueMap<String, String> queryParams = new LinkedMultiValueMap<>();
queryParams.add("access_token", accessToken);
queryParams.add("refresh_token", refreshToken);
queryParams.add("displayName", displayName)
//http://localhost/receive-token.html?access_token=XXX&refresh_token=YYY 형식으로 받도록 함.
return UriComponentsBuilder
.newInstance()
.scheme("http")
.host("se-sof.s3-website.ap-northeast-2.amazonaws.com")
.path("/login")
.queryParams(queryParams)
.build()
.encode() //⭐ 이것을 해주면 한글도 URI로 보낼 수 있다.
.toUri();
}
encode()
: UTF-8을 기본으로 인코딩 하는 메서드,