[Checkmate] OAuth2 소셜 로그인(구글, 카카오, 네이버) && 로그인 회원가입

Jifrozen·2022년 5월 24일
2

checkmate

목록 보기
3/4

이 글의 코드는 모두 이곳(https://deeplify.dev/back-end/spring/oauth2-social-login)을 참조하였다.

-> 코드의 흐름 알아보는 글

스프링은 인증과 인가기능을 가진 프레임워크인 Spring Security를 제공한다.

그 중 소셜로그인을 위해 제공하는 프로토콜이 OAuth이다.

OAuth란?

OAuth(Open Authorization)는 토큰 기반의 인증 및 권한을 위한 표준 프로토콜입니다. OAuth와 같은 인증 프로토콜을 통해 유저의 정보를 페이스북, 구글, 카카오 등의 서비스에서 제공받을 수 있고 이 정보를 기반으로 어플리케이션 사용자에게 로그인이나 다른 여러 기능들을 손쉽게 제공할 수 있습니다.

oauth를 통해 네이버, 카카오, 구글, 로컬 로그인을 진행한다.

차근차근 코드를 분석하면서 진행하겠지만 그래도 이해가 어려운 부분이 많다.

JWT란?

  1. 헤더 → 어떤 알고리즘을 쓰는지
  2. 페이로드 → 사용자의 정보를 담는다
  3. Signature → 비밀키를 이용해 위변조를 막음 (여기서 비밀키는 아무에게도 알려주면 안된다.)

이 세개를 인코딩한 문자열이 나온다. 그 문자열을 디코딩하면 위 3가지 정보가 나온다. 토큰안에 정보를 들고다니는거다.

JWT 생성하는 코드

AuthToken

/**
 * @package : com.checkmate.backend.oauth.token
 * @name: AuthToken.java
 * @date : 2022/05/22 5:13 오후
 * @author : jifrozen
 * @version : 1.0.0
 * @description : Jwt 패키지를 생성, 토큰 생성과 토큰의 유효성 검증 담당
 * @modified :
 **/
@Slf4j
@RequiredArgsConstructor
public class AuthToken {

	private static final String AUTHORITIES_KEY = "role";
	@Getter
	private final String token;
	private final Key key;

	AuthToken(String id, Date expiry, Key key) {
		this.key = key;
		this.token = createAuthToken(id, expiry);
	}

	AuthToken(String id, String role, Date expiry, Key key) {
		this.key = key;
		this.token = createAuthToken(id, role, expiry);
	}

	//토큰 생성
	private String createAuthToken(String id, Date expiry) {
		return Jwts.builder()
			.setSubject(id)
			.signWith(key, SignatureAlgorithm.HS256)
			.setExpiration(expiry)
			.compact();
	}

	private String createAuthToken(String id, String role, Date expiry) {
		return Jwts.builder()
			.setSubject(id)
			.claim(AUTHORITIES_KEY, role)
			.signWith(key, SignatureAlgorithm.HS256)
			.setExpiration(expiry)
			.compact();
	}
	// 검증 부분
	public boolean validate() {
		return this.getTokenClaims() != null;
	}

	public Claims getTokenClaims() {
		try {
			return Jwts.parserBuilder()
				.setSigningKey(key)
				.build()
				.parseClaimsJws(token)
				.getBody();
		} catch (SecurityException e) {
			log.info("Invalid JWT signature.");
		} catch (MalformedJwtException e) {
			log.info("Invalid JWT token.");
		} catch (ExpiredJwtException e) {
			log.info("Expired JWT token.");
		} catch (UnsupportedJwtException e) {
			log.info("Unsupported JWT token.");
		} catch (IllegalArgumentException e) {
			log.info("JWT token compact of handler are invalid.");
		}
		return null;
	}

	public Claims getExpiredTokenClaims() {
		try {
			Jwts.parserBuilder()
				.setSigningKey(key)
				.build()
				.parseClaimsJws(token)
				.getBody();
		} catch (ExpiredJwtException e) {
			log.info("expired JWT token.");
			return e.getClaims();
		}
		return null;
	}
}

AuthTokenProvider

@Slf4j
public class AuthTokenProvider {

	private static final String AUTHORITIES_KEY = "role";
	private final Key key;

	public AuthTokenProvider(String secret) {
		// 스트링인 yml 파일의 secret키를 인코딩 된 byte 배열로 변환후 다시 SecretKey로 변환해 주는 hmacShaKeyFor
		this.key = Keys.hmacShaKeyFor(secret.getBytes());
	}

	public AuthToken createAuthToken(String id, Date expiry) {
		return new AuthToken(id, expiry, key);
	}

	public AuthToken createAuthToken(String id, String role, Date expiry) {
		return new AuthToken(id, role, expiry, key);
	}
	// String -> token
	public AuthToken convertAuthToken(String token) {
		return new AuthToken(token, key);
	}

	// token 값에서 사용자의 정보를 꺼내는 함수
	public Authentication getAuthentication(AuthToken authToken) {

		if (authToken.validate()) {

			Claims claims = authToken.getTokenClaims();
			Collection<? extends GrantedAuthority> authorities =
				Arrays.stream(new String[] {claims.get(AUTHORITIES_KEY).toString()})
					.map(SimpleGrantedAuthority::new)
					.collect(Collectors.toList());

			log.debug("claims subject := [{}]", claims.getSubject());
			User principal = new User(claims.getSubject(), "", authorities);

			return new UsernamePasswordAuthenticationToken(principal, authToken, authorities);
		} else {
			throw new TokenValidFailedException();
		}
	}

}

JwtConfig

/**
 * @package : com.checkmate.backend.oauth.config
 * @name: JwtConfig.java
 * @date : 2022/05/22 5:38 오후
 * @author : jifrozen
 * @version : 1.0.0
 * @description : Jwt을 사용하기 위한 설정
 * @modified :
 **/
@Configuration
public class JwtConfig {
	@Value("${jwt.secret}")
	private String secret;

	@Bean
	public AuthTokenProvider jwtProvider() {
		return new AuthTokenProvider(secret);
	}
}

OncePerRequestFilter란?

Filter base class that aims to guarantee a single execution per request dispatch, on any servlet container. It provides a doFilterInternal method with HttpServletRequest and HttpServletResponse arguments.

요청 당 한번의 실행을 보장 → 자원 낭비를 줄이기 위해 통일한 request 안에서 한번만 필터링 해주는 filter이다.

따라서 여기서는 request안에 토큰 값을 받아 유효한지 확인해주는 작업을 한다.

출처

https://emgc.tistory.com/119

TokenAuthenticationFilter

/**
 * @package : com.checkmate.backend.oauth.filter
 * @name: TokenAuthenticationFilter.java
 * @date : 2022/05/22 5:38 오후
 * @author : jifrozen
 * @version : 1.0.0
 * @description : Generating Token / Validating JWT 과정에서 첫번째 필터 OncePerRequestFilter -> 토큰을 유효성 검사
 * @modified :
 **/
@Slf4j
@RequiredArgsConstructor
public class TokenAuthenticationFilter extends OncePerRequestFilter {

	private final AuthTokenProvider tokenProvider;

	@Override
	protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
		FilterChain filterChain) throws ServletException, IOException {

		String tokenStr = HeaderUtil.getAccessToken(request);
		AuthToken token = tokenProvider.convertAuthToken(tokenStr);

		if (token.validate()) {
			Authentication authentication = tokenProvider.getAuthentication(token);
			SecurityContextHolder.getContext().setAuthentication(authentication);
		}
		//다음 필터로 이동
		filterChain.doFilter(request, response);

	}
}

ProviderType

로그인은 지공해주는 provider를 enum타입으로 선언

@Getter
public enum ProviderType {
	GOOGLE,
	NAVER,
	KAKAO,
	LOCAL;
}

RoleType

주체의 인가를 enum타입으로 정의

@Getter
@AllArgsConstructor
public enum RoleType {
	USER("ROLE_USER", "일반 사용자 권한"),
	ADMIN("ROLE_ADMIN", "관리자 권한"),
	GUEST("GUEST", "게스트 권한");

	private final String code;
	private final String displayName;

	public static RoleType of(String code) {
		return Arrays.stream(RoleType.values())
			.filter(r -> r.getCode().equals(code))
			.findAny()
			.orElse(GUEST);
	}
}

User

@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "USER")
public class User {
	@JsonIgnore
	@Id
	@Column(name = "USER_SEQ")
	@GeneratedValue(strategy = GenerationType.IDENTITY)
	private Long userSeq;

	@Column(name = "USER_ID", length = 64, unique = true)
	@NotNull
	@Size(max = 64)
	private String userId;

	@Column(name = "USERNAME", length = 100)
	@NotNull
	@Size(max = 100)
	private String username;

	@JsonIgnore
	@Column(name = "PASSWORD", length = 128)
	@NotNull
	@Size(max = 128)
	private String password;

	@Column(name = "PROVIDER_TYPE", length = 20)
	@Enumerated(EnumType.STRING)
	@NotNull
	private ProviderType providerType;

	@Column(name = "ROLE_TYPE", length = 20)
	@Enumerated(EnumType.STRING)
	@NotNull
	private RoleType roleType;

	@Column(name = "CREATED_AT")
	@NotNull
	private LocalDateTime createdAt;

	@Column(name = "MODIFIED_AT")
	@NotNull
	private LocalDateTime modifiedAt;

	public User(
		@NotNull @Size(max = 64) String userId,
		@NotNull @Size(max = 100) String username,
		@NotNull @Size(max = 128) String password,
		@NotNull ProviderType providerType,
		@NotNull RoleType roleType,
		@NotNull LocalDateTime createdAt,
		@NotNull LocalDateTime modifiedAt
	) {
		this.userId = userId;
		this.username = username;
		this.password = password;
		this.providerType = providerType;
		this.roleType = roleType;
		this.createdAt = createdAt;
		this.modifiedAt = modifiedAt;
	}

}

UserRepository

@Repository
public interface UserRepository extends JpaRepository<User, Long> {
   User findByUserId(String userId);
}

UserPrincipal

인증된 스프링 시큐리티의 principal(주제 정보)를 나타낸다.

OAuth2User → 소셜로그인

UserDetauls → 로컬 로그인

oidcUser → openID

/**
 *@package: com.checkmate.backend.oauth.entity
 *@name:UserPrincipal.java
 *@date: 2022/05/22 6:20 오후
 *@author: jifrozen
 *@version: 1.0.0
 *@description: Spring Security에서 소셜 로그인 / 로컬 로그인 정보 담고있음
 *@modified:
 **/
@Getter
@Setter
@AllArgsConstructor
@RequiredArgsConstructor
public class UserPrincipal implements OAuth2User, UserDetails, OidcUser {
   private final String userId;
   private final String password;
   private final ProviderType providerType;
   private final RoleType roleType;
   private final Collection<GrantedAuthority> authorities;
   private Map<String, Object> attributes;

   public static UserPrincipal create(User user) {
      return new UserPrincipal(
         user.getUserId(),
         user.getPassword(),
         user.getProviderType(),
         RoleType.USER,
         Collections.singletonList(new SimpleGrantedAuthority(RoleType.USER.getCode()))
      );
   }

   public static UserPrincipal create(User user, Map<String, Object> attributes) {
      UserPrincipal userPrincipal =create(user);
      userPrincipal.setAttributes(attributes);

      return userPrincipal;
   }

   @Override
   public Map<String, Object> getAttributes() {
      return attributes;
   }

   @Override
   public Collection<? extends GrantedAuthority> getAuthorities() {
      return authorities;
   }

   @Override
   public String getName() {
      return userId;
   }

   @Override
   public String getUsername() {
      return userId;
   }

   @Override
   public boolean isAccountNonExpired() {
      return true;
   }

   @Override
   public boolean isAccountNonLocked() {
      return true;
   }

   @Override
   public boolean isCredentialsNonExpired() {
      return true;
   }

   @Override
   public boolean isEnabled() {
      return true;
   }

   @Override
   public Map<String, Object> getClaims() {
      return null;
   }

   @Override
   public OidcUserInfo getUserInfo() {
      return null;
   }

   @Override
   public OidcIdToken getIdToken() {
      return null;
   }
}

CustomOAuth2UserService

loadUser 메서드는 서드파티(구글, 카카오, 네이버,,)에 사용자 정보를 요청할 수 있는 accessToken을 얻고 나서 실행된다. 이 때 token같은 정보들이 OAuth2UserRequest안에 들어있다.

  1. access token을 이용해 서드파티 서버로부터 사용자 정보를 받아온다.
  2. 해당 사용자가 이미 회원가입 되어있는 사용자인지 확인한다.만약 회원가입이 되어있지 않다면, 회원가입 처리한다.만약 회원가입이 되어있다면 프로필 이름 같은 정보를 업데이트한다.
  3. UserPrincipal 을 return 한다. 세션 방식에서는 여기서 return한 객체가 시큐리티 세션에 저장된다.하지만 JWT 방식에서는 저장하지 않는다.(JWT 방식에서는 인증&인가 수행시 HttpSession을 사용하지 않을 것이다.)

출처 - https://yelimkim98.tistory.com/49?category=958402

/**
 * @package : com.checkmate.backend.oauth.service
 * @name: CustomOAuth2UserService.java
 * @date : 2022/05/22 6:24 오후
 * @author : jifrozen
 * @version : 1.0.0
 * @description : 소셜로그인 처리하는 Service
 * @modified :
 **/
@Service
@RequiredArgsConstructor
public class CustomOAuth2UserService extends DefaultOAuth2UserService {

	private final UserRepository userRepository;

	@Override
	public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
		OAuth2User user = super.loadUser(userRequest);

		try {
			return this.process(userRequest, user);
		} catch (AuthenticationException ex) {
			throw ex;
		} catch (Exception ex) {
			ex.printStackTrace();
			throw new InternalAuthenticationServiceException(ex.getMessage(), ex.getCause());
		}
	}

	private OAuth2User process(OAuth2UserRequest userRequest, OAuth2User user) {
		// naver google kakao 구분 짓는 코드
		ProviderType providerType = ProviderType.valueOf(
			userRequest.getClientRegistration().getRegistrationId().toUpperCase());

		OAuth2UserInfo userInfo = OAuth2UserInfoFactory.getOAuth2UserInfo(providerType, user.getAttributes());
		// 회원가입 된 User 정보 찾음
		User savedUser = userRepository.findByUserId(userInfo.getId());

		if (savedUser != null) {//회원가입 o
			if (providerType != savedUser.getProviderType()) {
				throw new OAuthProviderMissMatchException(
					"Looks like you're signed up with " + providerType +
						" account. Please use your " + savedUser.getProviderType() + " account to login."
				);
			}
			updateUser(savedUser, userInfo);
		} else {//회원가입 x
			savedUser = createUser(userInfo, providerType);
		}

		return UserPrincipal.create(savedUser, user.getAttributes());
	}
	//첫 로그인시 회원가입
	private User createUser(OAuth2UserInfo userInfo, ProviderType providerType) {
		LocalDateTime now = LocalDateTime.now();
		User user = new User(
			userInfo.getId(),
			userInfo.getName(),
			"No_Password",
			providerType,
			RoleType.USER,
			now,
			now
		);

		return userRepository.saveAndFlush(user);
	}

	private User updateUser(User user, OAuth2UserInfo userInfo) {
		if (userInfo.getName() != null && !user.getUsername().equals(userInfo.getName())) {
			user.setUsername(userInfo.getName());
		}

		return user;
	}
}

OAuth2UserInfoFactory

/**
 *@package: com.checkmate.backend.oauth.info
 *@name:OAuth2UserInfoFactory.java
 *@date: 2022/05/23 12:59 오전
 *@author: jifrozen
 *@version: 1.0.0
 *@description: ProviderType에 따라 UserInfo 나눔
 *@modified:
 **/
public class OAuth2UserInfoFactory {
   public static OAuth2UserInfo getOAuth2UserInfo(ProviderType providerType, Map<String, Object> attributes) {
      switch (providerType) {
         caseGOOGLE:
            return new GoogleOAuth2UserInfo(attributes);
         caseNAVER:
            return new NaverOAuth2UserInfo(attributes);
         caseKAKAO:
            return new KakaoOAuth2UserInfo(attributes);
         default:
            throw new IllegalArgumentException("Invalid Provider Type.");
      }
   }
}

CustomUserDetailsService

/**
 *@package: com.checkmate.backend.oauth.service
 *@name:CustomUserDetailService.java
 *@date: 2022/05/23 1:26 오전
 *@author: jifrozen
 *@version: 1.0.0
 *@description: local 로그인을 위함 유저의 정보를 가져옴
 *@modified:
 **/
@Service
@RequiredArgsConstructor
public class CustomUserDetailService implements UserDetailsService {

   private final UserRepository userRepository;

   @Override
   public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
      User user = userRepository.findByUserId(username);
      if (user == null) {
         throw new UsernameNotFoundException("Can not find username.");
      }
      return UserPrincipal.create(user);
   }
}

OAuth2AuthenticationFailureHandler

에러가 발생하게되면, user는 client로 redirect되며 이때 에러 메세지는 Query String으로 전달

/**
 * @package : com.checkmate.backend.oauth.handler
 * @name: OAuth2AuthenticationFailureHandler.java
 * @date : 2022/05/23 1:26 오전
 * @author : jifrozen
 * @version : 1.0.0
 * @description : OAuth2 authentication 수행중에 어떠한 error라도 발생하게되면, OAuth2AuthenticationFailureHandler가 invoke
 * @modified :
 **/
@Component
@RequiredArgsConstructor
public class OAuth2AuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler {

   private final OAuth2AuthorizationRequestBasedOnCookieRepository authorizationRequestRepository;

   @Override
   public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
      AuthenticationException exception) throws
      IOException, ServletException {
      String targetUrl = CookieUtil.getCookie(request,REDIRECT_URI_PARAM_COOKIE_NAME)
         .map(Cookie::getValue)
         .orElse(("/"));

      exception.printStackTrace();

      targetUrl = UriComponentsBuilder.fromUriString(targetUrl)
         .queryParam("error", exception.getLocalizedMessage())
         .build().toUriString();

      authorizationRequestRepository.removeAuthorizationRequestCookies(request, response);

      getRedirectStrategy().sendRedirect(request, response, targetUrl);
   }
}

OAuth2AuthenticationSuccessHandler

  • redirection uri에 대한 validation을 수행합니다.
  • unauthorized redirect uri로 요청이 들어온경우, 에러가 발생합니다.
  • JWT Token을 생성합니다.
  • user를 redirect_uri로 redirect 합니다. 이때 생성한 JWT Token을 Query String으로 전달합니다.
/**
 *@package: com.checkmate.backend.oauth.handler
 *@name:OAuth2AuthenticationFailureHandler.java
 *@date: 2022/05/23 1:29 오전
 *@author: jifrozen
 *@version: 1.0.0
 *@description: OAuth2AuthenticationSuccessHandler는 login 성공시 invoked
 *@modified:
 **/
@Component
@RequiredArgsConstructor
public class OAuth2AuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {

   private final AuthTokenProvider tokenProvider;
   private final AppProperties appProperties;
   private final UserRefreshTokenRepository userRefreshTokenRepository;
   private final OAuth2AuthorizationRequestBasedOnCookieRepository authorizationRequestRepository;

   @Override
   public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
      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);

      if (redirectUri.isPresent() && !isAuthorizedRedirectUri(redirectUri.get())) {
         throw new IllegalArgumentException(
            "Sorry! We've got an Unauthorized Redirect URI and can't proceed with the authentication");
      }

      String targetUrl = redirectUri.orElse(getDefaultTargetUrl());

      OAuth2AuthenticationToken authToken = (OAuth2AuthenticationToken)authentication;
      ProviderType providerType = ProviderType.valueOf(authToken.getAuthorizedClientRegistrationId().toUpperCase());

      OidcUser user = ((OidcUser)authentication.getPrincipal());
      OAuth2UserInfo userInfo = OAuth2UserInfoFactory.getOAuth2UserInfo(providerType, user.getAttributes());
      Collection<? extends GrantedAuthority> authorities = ((OidcUser)authentication.getPrincipal()).getAuthorities();

      RoleType roleType = hasAuthority(authorities, RoleType.ADMIN.getCode()) ? RoleType.ADMIN: RoleType.USER;

      Date now = new Date();
      AuthToken accessToken = tokenProvider.createAuthToken(
         userInfo.getId(),
         roleType.getCode(),
         new Date(now.getTime() + appProperties.getAuth().getTokenExpiry())
      );

      // refresh 토큰 설정
      long refreshTokenExpiry = appProperties.getAuth().getRefreshTokenExpiry();

      AuthToken refreshToken = tokenProvider.createAuthToken(
         appProperties.getAuth().getTokenSecret(),
         new Date(now.getTime() + refreshTokenExpiry)
      );

      // DB 저장
      UserRefreshToken userRefreshToken = userRefreshTokenRepository.findByUserId(userInfo.getId());
      if (userRefreshToken != null) {
         userRefreshToken.setRefreshToken(refreshToken.getToken());
      } else {
         userRefreshToken = new UserRefreshToken(userInfo.getId(), refreshToken.getToken());
         userRefreshTokenRepository.saveAndFlush(userRefreshToken);
      }

      int cookieMaxAge = (int)refreshTokenExpiry / 60;

      CookieUtil.deleteCookie(request, response,REFRESH_TOKEN);
      CookieUtil.addCookie(response,REFRESH_TOKEN, refreshToken.getToken(), cookieMaxAge);

      return UriComponentsBuilder.fromUriString(targetUrl)
         .queryParam("token", accessToken.getToken())
         .build().toUriString();
   }

   protected void clearAuthenticationAttributes(HttpServletRequest request, HttpServletResponse response) {
      super.clearAuthenticationAttributes(request);
      authorizationRequestRepository.removeAuthorizationRequestCookies(request, response);
   }

   private boolean hasAuthority(Collection<? extends GrantedAuthority> authorities, String authority) {
      if (authorities == null) {
         return false;
      }

      for (GrantedAuthority grantedAuthority : authorities) {
         if (authority.equals(grantedAuthority.getAuthority())) {
            return true;
         }
      }
      return false;
   }

   private boolean isAuthorizedRedirectUri(String uri) {
      URI clientRedirectUri = URI.create(uri);

      return appProperties.getOauth2().getAuthorizedRedirectUris()
         .stream()
         .anyMatch(authorizedRedirectUri -> {
            // Only validate host and port. Let the clients use different paths if they want to
            URI authorizedURI = URI.create(authorizedRedirectUri);
            if (authorizedURI.getHost().equalsIgnoreCase(clientRedirectUri.getHost())
               && authorizedURI.getPort() == clientRedirectUri.getPort()) {
               return true;
            }
            return false;
         });
   }
}

RestAuthenticationEntryPoint

기존에 Exception 처리를 위해 ExceptionAdvice를 만들어줬다. 하지만 Spring Security에서 발생하는 오류는 Exception 처리보다 앞단에서 일어나기 때문에 따로 처리해줘야한다.

@Slf4j
public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint {
   @Override
   public void commence(
      HttpServletRequest request,
      HttpServletResponse response,
      AuthenticationException authException
   ) throws IOException, ServletException {
      authException.printStackTrace();
log.info("Responding with unauthorized error. Message := {}", authException.getMessage());
      response.sendError(
         HttpServletResponse.SC_UNAUTHORIZED,
         authException.getLocalizedMessage()
      );
   }
}

AppProperties

application.yml 파일에서 JWT Configuartion을 binding하는 POJO 클래스

/**
 *@package: com.checkmate.backend.oauth.config
 *@name:AppProperties.java
 *@date: 2022/05/23 1:47 오전
 *@author: jifrozen
 *@version: 1.0.0
 *@description: 정의한 토큰 정보를 자바 코드에서 가져다 쓰기 위함
 *@modified:
 **/
@Getter
@ConfigurationProperties(prefix = "app")
public class AppProperties {

   private final Auth auth = new Auth();
   private final OAuth2 oauth2 = new OAuth2();

   @Getter
   @Setter
   @NoArgsConstructor
   @AllArgsConstructor
   public static class Auth {
      private String tokenSecret;
      private long tokenExpiry;
      private long refreshTokenExpiry;
   }

   public static final class OAuth2 {
      private List<String> authorizedRedirectUris = new ArrayList<>();

      public List<String> getAuthorizedRedirectUris() {
         return authorizedRedirectUris;
      }

      public OAuth2 authorizedRedirectUris(List<String> authorizedRedirectUris) {
         this.authorizedRedirectUris = authorizedRedirectUris;
         return this;
      }
   }
}

CorsProperties

@Getter
@Setter
@ConfigurationProperties(prefix = "cors")
public class CorsProperties {
   private String allowedOrigins;
   private String allowedMethods;
   private String allowedHeaders;
   private Long maxAge;
}

SecurityConfig

/**
 * @package : com.checkmate.backend.oauth.config
 * @name: SecurityConfig.java
 * @date : 2022/05/23 1:51 오전
 * @author : jifrozen
 * @version : 1.0.0
 * @description : Spring security를 위한 설정 파일
 * @modified :
 **/
@Configuration
@RequiredArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter {

	private final CorsProperties corsProperties;
	private final AppProperties appProperties;
	private final AuthTokenProvider tokenProvider;
	private final CustomUserDetailService userDetailsService;
	private final CustomOAuth2UserService oAuth2UserService;
	private final TokenAccessDeniedHandler tokenAccessDeniedHandler;
	private final UserRefreshTokenRepository userRefreshTokenRepository;

	/*
	 * UserDetailsService 설정
	 * */
	@Override
	protected void configure(AuthenticationManagerBuilder auth) throws Exception {
		auth.userDetailsService(userDetailsService)
			.passwordEncoder(passwordEncoder());
	}

	@Override
	protected void configure(HttpSecurity http) throws Exception {
		http
        //h2 오류로 추가
        .headers().frameOptions().sameOrigin().and()
			.cors()//cors 허용
			.and()
			.sessionManagement() // session Creation Policy를 STATELESS로 정의해 session을 사용하지 않겠다 선언
			.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
			.and()
			//REST API 이기 떄문에 사용 x
			.csrf().disable()
			.formLogin().disable()
			.httpBasic().disable()
			.exceptionHandling()
			// 사용자가 authentication 없이 protected resource에 접근하는 경우에 invoked 되는 entry point
			.authenticationEntryPoint(new RestAuthenticationEntryPoint())
			//인가되지않은 사용자 Handler
			.accessDeniedHandler(tokenAccessDeniedHandler)
			.and()
			// 경로 허용 경우 설정
			.authorizeRequests()
			.requestMatchers().permitAll()
            ..antMatchers("/**").permitAll()
			//.requestMatchers(CorsUtils::isPreFlightRequest).permitAll()
			.antMatchers("/api/**","**/oauth/**").permitAll()
			//.antMatchers("/api/**").hasAnyAuthority(RoleType.USER.getCode())
			//.antMatchers("/api/**/admin/**").hasAnyAuthority(RoleType.ADMIN.getCode())
			//.anyRequest().authenticated()
			.and()
			//OAuth2 로그인 설정 부분
			.oauth2Login()
			// oauth 로그인시 접근할 end point를 정의합니다
			.authorizationEndpoint()
			.baseUri("/oauth2/authorization")
			.authorizationRequestRepository(oAuth2AuthorizationRequestBasedOnCookieRepository())
			.and()
			.redirectionEndpoint()
			.baseUri("/*/oauth2/code/*")
			.and()
			// 로그인시 사용할 User Service를 정의
			.userInfoEndpoint()
			.userService(oAuth2UserService)
			.and()
			//성공 실패 Handler
			.successHandler(oAuth2AuthenticationSuccessHandler())
			.failureHandler(oAuth2AuthenticationFailureHandler());

		//reqeust 요청이 올때마다 UsernamePasswordAuthenticationFilter 이전에 tokenAuthenticaitonFilter를 수행하도록 정의
		http.addFilterBefore(tokenAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
	}

	/*
	 * auth 매니저 설정
	 * */
	@Override
	@Bean(BeanIds.AUTHENTICATION_MANAGER)
	protected AuthenticationManager authenticationManager() throws Exception {
		return super.authenticationManager();
	}

	/*
	 * security 설정 시, 사용할 인코더 설정
	 * */
	@Bean
	public BCryptPasswordEncoder passwordEncoder() {
		return new BCryptPasswordEncoder();
	}

	/*
	 * 토큰 필터 설정
	 * */
	@Bean
	public TokenAuthenticationFilter tokenAuthenticationFilter() {
		return new TokenAuthenticationFilter(tokenProvider);
	}

	/*
	 * 쿠키 기반 인가 Repository
	 * 인가 응답을 연계 하고 검증할 때 사용.
	 * */
	@Bean
	public OAuth2AuthorizationRequestBasedOnCookieRepository oAuth2AuthorizationRequestBasedOnCookieRepository() {
		return new OAuth2AuthorizationRequestBasedOnCookieRepository();
	}

	/*
	 * Oauth 인증 성공 핸들러
	 * */
	@Bean
	public OAuth2AuthenticationSuccessHandler oAuth2AuthenticationSuccessHandler() {
		return new OAuth2AuthenticationSuccessHandler(
			tokenProvider,
			appProperties,
			userRefreshTokenRepository,
			oAuth2AuthorizationRequestBasedOnCookieRepository()
		);
	}

	/*
	 * Oauth 인증 실패 핸들러
	 * */
	@Bean
	public OAuth2AuthenticationFailureHandler oAuth2AuthenticationFailureHandler() {
		return new OAuth2AuthenticationFailureHandler(oAuth2AuthorizationRequestBasedOnCookieRepository());
	}

	/*
	 * Cors 설정
	 * */
	@Bean
	public UrlBasedCorsConfigurationSource corsConfigurationSource() {
		UrlBasedCorsConfigurationSource corsConfigSource = new UrlBasedCorsConfigurationSource();

		CorsConfiguration corsConfig = new CorsConfiguration();
		corsConfig.setAllowedHeaders(Arrays.asList(corsProperties.getAllowedHeaders().split(",")));
		corsConfig.setAllowedMethods(Arrays.asList(corsProperties.getAllowedMethods().split(",")));
		corsConfig.setAllowedOrigins(Arrays.asList(corsProperties.getAllowedOrigins().split(",")));
		corsConfig.setAllowCredentials(true);
		corsConfig.setMaxAge(corsConfig.getMaxAge());

		corsConfigSource.registerCorsConfiguration("/**", corsConfig);
		return corsConfigSource;
	}
}

AuthController

/**
 *@package: com.checkmate.backend.oauth.api.controller
 *@name:AuthController.java
 *@date: 2022/05/23 2:10 오전
 *@author: jifrozen
 *@version: 1.0.0
 *@description: 로그인 처리하는 Controller
 *@modified:
 **/
@Tag(name = "OAuth2", description = "로그인 관련 API")
@RestController
@RequestMapping("/api/v1/auth")
@RequiredArgsConstructor
public class AuthController {

   private final static longTHREE_DAYS_MSEC= 259200000;
   private final static StringREFRESH_TOKEN= "refresh_token";
   private final AppProperties appProperties;
   private final AuthTokenProvider tokenProvider;
   private final AuthenticationManager authenticationManager;
   private final UserRefreshTokenRepository userRefreshTokenRepository;
   private final ResponseService responseService;

   @PostMapping("/login")
   public SingleResult<String> login(
      HttpServletRequest request,
      HttpServletResponse response,
      @RequestBody AuthReqModel authReqModel
   ) {
      Authentication authentication = authenticationManager.authenticate(
         new UsernamePasswordAuthenticationToken(
            authReqModel.getId(),
            authReqModel.getPassword()
         )
      );

      String userId = authReqModel.getId();
      SecurityContextHolder.getContext().setAuthentication(authentication);

      Date now = new Date();
      AuthToken accessToken = tokenProvider.createAuthToken(
         userId,
         ((UserPrincipal)authentication.getPrincipal()).getRoleType().getCode(),
         new Date(now.getTime() + appProperties.getAuth().getTokenExpiry())
      );

      long refreshTokenExpiry = appProperties.getAuth().getRefreshTokenExpiry();
      AuthToken refreshToken = tokenProvider.createAuthToken(
         appProperties.getAuth().getTokenSecret(),
         new Date(now.getTime() + refreshTokenExpiry)
      );

      // userId refresh token 으로 DB 확인
      UserRefreshToken userRefreshToken = userRefreshTokenRepository.findByUserId(userId);
      if (userRefreshToken == null) {
         // 없는 경우 새로 등록
         userRefreshToken = new UserRefreshToken(userId, refreshToken.getToken());
         userRefreshTokenRepository.saveAndFlush(userRefreshToken);
      } else {
         // DB에 refresh 토큰 업데이트
         userRefreshToken.setRefreshToken(refreshToken.getToken());
      }

      int cookieMaxAge = (int)refreshTokenExpiry / 60;
      CookieUtil.deleteCookie(request, response,REFRESH_TOKEN);
      CookieUtil.addCookie(response,REFRESH_TOKEN, refreshToken.getToken(), cookieMaxAge);

      return responseService.getSingleResult(accessToken.getToken());
   }

   @GetMapping("/refresh")
   public SingleResult<String> refreshToken(HttpServletRequest request, HttpServletResponse response) {
      // access token 확인
      String accessToken = HeaderUtil.getAccessToken(request);
      AuthToken authToken = tokenProvider.convertAuthToken(accessToken);
      if (!authToken.validate()) {
         throw new TokenValidFailedException();
      }

      // expired access token 인지 확인
      Claims claims = authToken.getExpiredTokenClaims();
      if (claims == null) {
         throw new TokenValidFailedException("유효한 access token.");
      }

      String userId = claims.getSubject();
      RoleType roleType = RoleType.of(claims.get("role", String.class));

      // refresh token
      String refreshToken = CookieUtil.getCookie(request,REFRESH_TOKEN)
         .map(Cookie::getValue)
         .orElse((null));
      AuthToken authRefreshToken = tokenProvider.convertAuthToken(refreshToken);

      if (authRefreshToken.validate()) {
         throw new TokenValidFailedException("refresh Token 유효하지 않음.");
      }

      // userId refresh token 으로 DB 확인
      UserRefreshToken userRefreshToken = userRefreshTokenRepository.findByUserIdAndRefreshToken(userId,
         refreshToken);
      if (userRefreshToken == null) {
         throw new TokenValidFailedException("refresh Token 유효하지 않음.");
      }

      Date now = new Date();
      AuthToken newAccessToken = tokenProvider.createAuthToken(
         userId,
         roleType.getCode(),
         new Date(now.getTime() + appProperties.getAuth().getTokenExpiry())
      );

      long validTime = authRefreshToken.getTokenClaims().getExpiration().getTime() - now.getTime();

      // refresh 토큰 기간이 3일 이하로 남은 경우, refresh 토큰 갱신
      if (validTime <=THREE_DAYS_MSEC) {
         // refresh 토큰 설정
         long refreshTokenExpiry = appProperties.getAuth().getRefreshTokenExpiry();

         authRefreshToken = tokenProvider.createAuthToken(
            appProperties.getAuth().getTokenSecret(),
            new Date(now.getTime() + refreshTokenExpiry)
         );

         // DB에 refresh 토큰 업데이트
         userRefreshToken.setRefreshToken(authRefreshToken.getToken());

         int cookieMaxAge = (int)refreshTokenExpiry / 60;
         CookieUtil.deleteCookie(request, response,REFRESH_TOKEN);
         CookieUtil.addCookie(response,REFRESH_TOKEN, authRefreshToken.getToken(), cookieMaxAge);
      }

      return responseService.getSingleResult(newAccessToken.getToken());
   }
}

UserController

/**
 *@package: com.checkmate.backend.oauth.api.controller
 *@name:UserController.java
 *@date: 2022/05/23 2:15 오전
 *@author: jifrozen
 *@version: 1.0.0
 *@description: 사용자 관리 Controller
 *@modified:
 **/
@RestController
@RequestMapping("/api/v1/users")
@RequiredArgsConstructor
public class UserController {

   private final UserService userService;
   private final ResponseService responseService;

   @GetMapping
   public SingleResult<User> getUser() {
      org.springframework.security.core.userdetails.User principal = (org.springframework.security.core.userdetails.User)SecurityContextHolder
         .getContext().getAuthentication().getPrincipal();

      User user = userService.getUser(principal.getUsername());

      return responseService.getSingleResult(user);
   }

   	//회원가입 로직 추가 필요
	@Operation(summary = "회원가입", description = "회원가입 요청")
	@PostMapping("/join")
	public SingleResult<User> join(@RequestBody @Parameter(description = "회원가입 정보", required = true) UserDto user) {
		log.info("회원가입 - {}", user);
		User signedUser = userService.signUpUser(user);
		return responseService.getSingleResult(signedUser);
	}
}

https://minholee93.tistory.com/entry/Spring-Security-Google-Login-with-Spring-Security-JWT-2?category=924032

0개의 댓글