실습 목표
OAuth2.0 클라이언트와 스프링 시큐리티 6 프레임 워크를 활용하여 신뢰할 수 있는 외부 사이트 ( 구글, 네이버)로 부터 인증을 받고 전달 받은 유저 데이터를 활용하여 JWT를 발급 하고 인가를 진행하는 방법
인증 받은 데이터는 MariaDB를 활용하고 저장
구현
세션 방식의 모식도
JWT 방식에서는 로그인(인증)이 성공 하면 JWT 발급 문제와 웹. 하이브리드. 네이티브앱 별 특징에 의해 OAuth2 Code Grant 방식 동작의 책임을 프론트엔드 측에 둘 것인지 백엔드 측에 둘 것인지 많은 고민을 한다
모든 책임을 프론트가 맡을 시
이 경우 편하게 구성할 수는 있지만, 프론트 측에서 보낸 유저 정보의 진위를 따지는 게 중요하다.
추가적인 보안로직 구성이 까다로워질 수 있다.
책임을 프론트/백에 분배해서 할시
대부분의 웹 블로그가 구현한 방식이지만 코드/ Acess 토큰을 전송하는 방법을 지양한다. (보안 규격 등의 이유)
모든 책임을 백엔드가 맡을 경우
하이퍼링크로 요청 했기 때문에 백엔드 측에서 JWT를 획득하기 매우 까다롭다.
구현할 방식
로그인 페이지 요청 - > 코드 발급 -> Access 토큰 -> 유저 정보 획득 -> JWT 발급 모두 Spring 에서 처리할 계획이다.
참고
앱 개발시 프론트 방식 권장
웹 개발시 백엔드 방식 권장
액세스 토큰, 코드 API 전송 지양
JWTFilter
우리가 직접 커스텀해서 등록 해야 한다
모든 주소에서 동작
OAuth2AuthorizationRequestRedirectFilter
/oauth2/authorization/서비스명
/oauth2/authoriziation/naver
/oauth2/auothorziation/google
OAuth2LoginAuthenticationFilter 외부 인증 서버에 설정할 redirect_url
/login/oauth2/code/서비스명
/login/oauth2/code/naver
/login/oauth2/code/google
Filter 들은 자동으로 설정이 된다
DB에 저장할 로직과 로그인 성공시 실행될 로직
토큰을 검증할 필터, 토큰을 발급하고 검증할 로직이 담겨있는 필터
SecurityConfig 기본 세팅
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
httpSecurity.csrf((auth) -> auth.disable())
.formLogin((auth) -> auth.disable())
.httpBasic((auth) -> auth.disable())
.oauth2Login(Customizer.withDefaults())
.authorizeHttpRequests((auth) -> auth
.requestMatchers("/").permitAll()
.anyRequest().authenticated()
)
.sessionManagement((session) -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
return httpSecurity.build();
}
}
진행하는 예제에 필요에 따라 각 옵션 disable 과 SessionManagement를 통해 SessionCreationPolicy.STATELESS로 설정 하였다.
기본적인 SecurityConfig 세팅 완료
추후에 인증, 인가를 추가 하거나 JWT를 발급하고, JWT를 검증할 필터를 추가하고, OAuth2 로그인 값도 커스텀하는 등의 추가 과정이 필요하다.
csrf().disable()
세션 기반의 웹 사이트의 경우,CRSF 공격 방지차 서버에서 생성한 CSRF토큰을 쿠키나 세션에 저장하는데, JWT방식의 경우 이를 저장할 세션이 존재하기 때문에 다른 방식의 보안 절차를 수행한다.
httpBasic().disable()
사용자명 비밀번호를 텍스트로 전송하는 가장 기본적인 인증 방식으로 보안에 취약해 JWT와 같은 암호화 토큰 기반의 인증 방식을 사용할 때엔 disable() 한다
sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
JWT는 상태정보를 저장하지 않는 statless 한 특징을 가지고 있다.
스프링 시큐리티는 기본적으로 인증에 필요한 요청에 대해 세션을 생성하고 관리하는 기능을 제공하는데, JWT인증 방식에서는 세션 생성이 필요 없기 때문에 disable() 한다.
JWT 발급과 검증
JWT에 관해 발급과 검증을 담당할 클래스가 필요하다. 따라서 JWTUtil 이라는 클래스를 생성하여 JWT 발급, 검증 메소드를 작성한다.
JWT
JWT의 특징은 내부 정보를 단순 BASE64 방식으로 인코딩 하기 때문에 외부에서 쉽게 디코딩 할수 있다.
외부에서 열람해도 되는 정보를 담아야 하며, 토큰 자체의 발급처를 확인하기 위해서 사용한다.
(지폐와 같이 외부에서 그 금액을 확인하고 금방 외형을 따라 만들 수 있지만 발급처에 대한 보장 및 검증은 확실하게 해야하는 경우에 사용 한다. 따라서 토큰 내부에 비밀번호와 같은 값 입력을 금지 한다.)
암호화 키 저장
@Component
public class JWTUtil {
private SecretKey secretKey;
public JWTUtil(@Value("${spring.jwt.secret}")String secret) {
secretKey = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), Jwts.SIG.HS256.key().build().getAlgorithm());
}
public String getUsername(String token) {
return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("username", String.class);
}
public String getRole(String token) {
return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("role", String.class);
}
public Boolean isExpired(String token) {
return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().getExpiration().before(new Date());
}
public String createJwt(String username, String role, Long expiredMs) {
return Jwts.builder()
.claim("username", username)
.claim("role", role)
.issuedAt(new Date(System.currentTimeMillis()))
.expiration(new Date(System.currentTimeMillis() + expiredMs))
.signWith(secretKey)
.compact();
}
}
getUsername 메서드 내부:
getRole 메서드 내부:
isExpired 메서드 내부:
즉, 주요 로직은 Jwts의 parser()를 시작으로 parseClaims() 까지입니다. 그 이후 각 메서드의 목적에 맞게 토큰 정보를 추출하고 검증하는 과정을 거치게 됩니다.
SecurityConfig
@EnabaleWebSecurity
@Configuration
@AllArgsConstuctor
public class SecurityConfig{
private final CustomOAuth2UserService cutomOAuth2UserService;
private final CustomSuccessHandler customSuccessHandler;
@Bean
public SecurityFilterChain securityFilterChain (HttpSecurity httpSecurity) throws Exception{
httpSecurity.
crsf(AbstractHttpConfigurer::disable)
.fromLogin(AbstractConfigurer::disable)
.httpBasic(AbstractConfigurer::disalbe)
.oauth2Login((oauth)->oauth.userInfoEndPoint(
(userInfoEndPointCoinfg
.userService(customOAuth2UserService))
.successHandler(customSuccessHandler))
.authorizationHttpRequest((auth)->auth
.requestMathcer("/").pertmiaAll()
.anyRequest().authenticated())
)
.sessionManagement((session)->session.sessionCreatePolicy(SessionCreatePolicy.STATELESS));
return httpSecurity.build();
}
}
userInfoEndPoint()
: OAuth2
로그인 성공 후 사용자 정보를 가져오는 endpoint 설정
userService(custimOAuth2UserService)
: 커스텀 UserService 빈을 지정
OAuth2Provider 에서 로그인이 성공하면 스프링 시큐리티가
/oauth2/authorization/google,naver
엔드포인트로 사용자 정보를 요청한다. 여기서 얻은 사용자 정보를 처리할customOAuthUserService
빈을 통해 후처리를 진행한다. 이를 통해 획득한 정보를 바탕으로 애플리케이션 레벨의 인증 및 권한처리 로직을 적용할 수 있다. 주로 DB연동, 가입/비가입 처리, 획득 정보 업데이트 등을 이 서비스에서 하게 된다.
CustomOAuth2UserService
1. 엔드 포인트를 설정,
2. 서비스별로 받을 Response 객체 생성
3. OAuth2User 구현 ->UserDTO-> CustomOAuth2User 반환
OAuth2User : 서버를 통해 얻어온 사용자 정보 자체
역할
소셜 로그인 성공 후 반환된 OAuth2User 객체에 필요한 사용자 정보 추출
최초가입시 DB 저장
public Class CustomOAuth2UserService with DefaultOAuth2UserService{
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException{
OAuth2User oAuth2User = super.loadUser(userReqeust);
String registrationId = userRequest.getClientRegistration().getRegistrationId();
OAuth2Response oAuth2Response=null;
if(registrationId.equals("naver")){
oAuth2Response = new NaverResponse(oAuth2User.getAttributes());
}
else if(registrationId.equals("google")){
oAuth2Response= new GoogleResponse(oAuth2User.getAttribute());
}else{
null;
}
String username= oAuth2Response.getProvider()+ " " + oAuth2Response.getProviderId();
UserDTO userDTo = new UserDTO;
userDto.setUsername(username);
userDto.setName(oAuth2Response.getName());
userDTO.setRole("ROLE_USER");
return new CustomOAuth2User(userDTO);
}
}
OAuth2Response.interface
public interface OAuth2Response{
String getProvider();
String getProviderId();
String getEmail();
String getName();
}
GoogleResponse
@RequiredArgsConstructor
public GoogleResponse implements OAuth2Response{
Map<String,Object> attribute;
@Override
String getProvider(){
return "google";
}
@Override
String getProviderId(){
retunr attribute.get("sub").toString();
}
@Override
String getEmail(){
return attribute.get("email").toString();
}
@Override
String getName(){
return attribute.get("name").toString();
}
}
NaverResponse
ass NaverResponse implements OAuth2Response {
private final Map<String, Object> attribute;
public NaverResponse(Map<String, Object> attribute) {
this.attribute = (Map<String,Object>) attribute.get("response");
}
@Override
public String getProvider() {
return "naver";
}
@Override
public String getProviderId() {
return attribute.get("id").toString();
}
@Override
public String getEmail() {
return attribute.get("email").toString();
}
@Override
public String getName() {
return attribute.get("name").toString();
}
CustomOAuth2User
사용자 정보 조회, 반환을 위한 OAuth2User 인터페이스 구현체
@RequiredArgsConstructor
public class CustomOAuth2User implements OAuth2User{
private final UserDTO userDTO;
@Override
public Map<String, Object> getAttribute(){
return null;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return Collections.singletonList(new SimpleGrantedAuthority(userDTO.getRole()));
}
@Override
public String getName(){
return userDTO.getName();
}
@Override
public String getUsername(){
return userDTO.getUsername();
}
}
DTO는 계층간 데이터 교환을 위한 객체이며 Entity는 DB 매핑을 위한 객체 이다.
OAuth2UserService
- 소셜/OAuth2 로그인시 프롭이더에서 전달 받은 사용자 정보 추출
-엔드포인트를 통해 사용자정보 획득
- 프로바이더 별로 다른 필드 추상화, 필드 추출
String registrationId = userRequest.getClientRegistration().getRegistrationId();
@Service
@RequiredArgsConstructor
public Class CustomOAuth2UserService with DefaultOAuth2UserService{
private final UserRepository userRepository;
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException{
OAuth2User oAuth2User = super.loadUser(userReqeust);
String registrationId = userRequest.getClientRegistration().getRegistrationId();
OAuth2Response oAuth2Response=null;
if(registrationId.equals("naver")){
oAuth2Response = new NaverResponse(oAuth2User.getAttributes());
}
else if(registrationId.equals("google")){
oAuth2Response= new GoogleResponse(oAuth2User.getAttribute());
}else{
null;
}
String username= oAuth2Response.getProvider()+ " " + oAuth2Response.getProviderId();
UserEntity existData = userRepository.findByUsername(username);
if(existData ==null){
UserEntity userentity = new UserEntity();
userEntity.setUsername(username);
userEntity.setName(oAuth2Response.getName());
userEntity.setEmail(oAuth2Response.getEmail());
userRepository.save(userEntity);
UserDTo userDto = new UserDto();
userDto.setUsername(username);
userDto.setName(oAuth2Response.getName());
userDto.setRoel("ROLE_USER");
return new CustomOAuth2User(userDTO);
}else{
exisData.setUsername(username);
existData.setEmail(oAuth2Response.getEmail());
existData.setName(oAuth2Response.getName());
userRepository.save(existData);
userDTO.setUsername(username);
userDTO.setUsername(existData.getUsername());
userDTO.setName(oAuth2Response.getNmae());
userDTO.setRole(exisData.getRole());
return new CustomOAuth2User(userDTO);
}
}
}
OAuth2User를 userReqeust 로 추출 하고, registrationId 또한 userRequest로 추출 한다.
각 OAuth 프로바이더 구분을 위해 임의로 username을 생성한다
값이 존재하지 않으면 생성
값이 존재하면 수정하는 식으로 CustomOAuth2User로 DTO에 담아 return 한다
JWTUtil
-JWT 암호화/복호화 메서드 제공
JWT 생성시 알고리즘 키 관리
토큰 파싱시 서명 검증이나 복호화 위한 메서드 제공
- Claim 생성 및 파싱
- 토큰에 담길 정보인 클레임 객체 생성 및 메서드 제공
- 토큰 파싱시 클레임 추출 및 검증 관련 메서드 제공- 토큰 생성 및 파싱
- 전체 토큰 객체 생성 및 서명, 시리얼라이징, 문자열 변환 등 기능 수행
- 문자열로 전달 받은 토큰 파싱, 검증 메서드 제공
JWT관련 다양한 연산을 위한 유틸리티 성격의 클래스 - 암호화 알고리즘, 키 관리, 클레임 처리
Claim은 Playload 부분에 담길 정보 조각이라고 보면 된다.
@Component
public class JWTUtil{
private final SecretKey secretKey;
public JWTUtil(@Value("${spring.jwt.secret}")String secret){
secretKey= new SecretKeySpec(secret.getBytes(StandardCharsets(UTF_8),Jwts.SIG.H256.key().build().getAlgorithm());
}
public String getUsername(String token){
return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("username",String.class);
}
public String getRole(String token) {
return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("roloe", String.class);
}
public Boolean isExpired(String token) {
return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().getExpiration().before(new Date());
}
public String createJwt(String username,String role,Long expiredMs){
return Jwts.builder()
.claim("username",username)
.claim("role",role)
.issuedAt(new Date(System.currenTimeMills()))
.expiration(new Date(System.currentTimeMills()+expiredMs))
.singWith(secretKey)
.compact();
}
}
CustomSuccessHandler
@Component
@RequiredArgsConstructor
public class CustomSuccessHandler extends SimpleUrlAuthenticationSuccessHandler{
private final JWTUtil jwtUtil;
@Override
public void onAuthenticationSuccess(HttpServeltRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletExcption{
CustomOAuth2User customUserDetails =(CustomOAuth2User)authentication.getPrincipal();
// 인증 사용자가 누구인지
String username= customUserDetails.getUsername();
//authentication.getPrincipal() 로 username 추출
Collection<? extends GrantedAuthoritiy> authorities= authentication.getAuthorities();
Iterator<? extends GrantedAuthority> iterator = authentication.getAuthorities().iterator();
GrandAuthority authority = iterator.next();
// 사용자 권한 정보 목록에서 첫 번째 권한을 가져옴
String role= authority.getAuthority();
String token = jwtUtil.createJwt(username, role, 60* 60*60L);
// JWTUtil 클래스에서 구현해놓은 토큰 생성 메서드를 실제 작동하는 부분
// authentication.getPrincipal()로 얻은 username 과
// authentication.getAuthorities() 에서 얻은 role 을 넣고
// 만료 시간을 넣음
response.addCookie(createCookie("Authorization",token));
// response에 쿠키를 addCookie() 하는데 Authorization 과 token을 함꼐 넣음
response.sendRedirect("http://localhost:3000/");
//리다이렉트
}
}
OAuth2 로그인 성공시 JWT 토큰을 생성, 쿠키에 담고, 프론트엔드 페이지로 리다이렉트
JWTFilter
JWT 토큰을 파싱하고 검증하는 과정을 진행하며 이를 바탕으로 인증 정보를 생설해주는 필터 역할을 수행한다. 인증 정보를 SecurityContext에 저장해주기 때문에 권한 검증, 인가 처리가 가능 해진다.
JWTFilter 가 UsernamePasswordAuthenticationFilter보다 앞단에 위치하는 이유
@RequiredArgsConstructor
public class JWTFilter extends OncePerRequestFilter{
private final JWTUtil jwtUtil;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)throws ServletException, IOException{
String authorization = null;
Cookie[]cookies = request.getCookies();
for(Cookie cookie : cookies){
if(cookie.getName().equals("Authorization")){
authorization = cookie.getValue();
}
}
if(authorization ==null){
sout("token null");
filterChain.doFilter(request,response);
return;
}
String token =authorization;
if(jwtUtil.isExpired(token)){
sout("token expired");
filterChain.doFilter(reqeust,response);
}
String username= jwtUtil.getUsername(token);
String role = jwtUtil.getRole(token);
UserDto userDto= new UserDto;
userDto.setUsername(username);
userDto.setRole(role);
//UserDetail에 회원 정보 객체 담기
CustomOAuth2User customOAuth2User = new CustomAuth2User(userDto);
//스프링 시큐리티 인증 토큰 생성
Authentication authToken = new UsernamePasswordAuthenticationToken(customOAuth2User, null, customOAuth2User.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authToken);
filterChaindoFilter(request,response);
}
}
요청에서 JWT 토큰을 파싱해 사용자 인증 정보를 SecurityContext 에 다시 설정하는 작업 진행
UsernamePasswordAuthenticationToken 매개변수
1. 사용자 정보 (principal) : 인증된 사용자 정보
2. 패스워드 ( credentials) : 자격 증명
3. 권한목록 (authorities)
OAuth2 진행이므로 비밀번호는 null 로 채워졌다.
FilterChain 흐름
1. 1. WebAsyncManagerIntegrationFilter
2. SecurityContextPersistenceFilter
3. HeaderWriterFilter
4. CorsFilter
5. CsrfFilter
6. LogoutFilter
7. JWT Filter
8. UsernamePasswordAuthenticationFilter
9. RequestCacheAwareFilter
10. SecurityContextHolderAwareRequestFilter
11. AnonymousAuthenticationFilter
12. SessionManagementFilter
13. ExceptionTranslationFilter
14. FilterSecurityInterceptor
AuthenticationFilter
사용자의 입력 정보나 토큰 정보 등을 바탕으로 Authentication 객체를 생성한다
예를 들어
UsernamePasswordAuthenticationFilter
는 전달 받은 username,password 파라메터를 통해UsernamePasswordAuthenticationToken
을 생성한다.
JwtAuthenticationFilter
는 JWT 토큰을 파싱해서 그 정보로 JwtAuthenticationToken을 생성한다.
생서된
Authentication
객체는AuthenticationManager
AuthenticationProvider
에 사용된다.