1편 - Local User (https://thcoding.tistory.com/114)
2편 - JWT (https://thcoding.tistory.com/115)
3편 - (현재글) OAuth2 (https://thcoding.tistory.com/116)
4편 - OAuth2 test(https://thcoding.tistory.com/118)
저번 포스팅 JWT에 이어서 oauth2 에 관련된 코드들에대해 리뷰를 해보겠다. 코드가 엄청 많다. 가장먼저 엔티티 정리부터 하고가자.
엔티티
ㅇ AuthReqModel
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class AuthReqModel {
private String id;
private String password;
}
로그인할때 사용된다. 나는 id 와 password만 입력 받을거다.
ㅇ ProviderType
@Getter
public enum ProviderType {
GOOGLE,
KAKAO,
LOCAL;
}
프로바이더 타입은 오어쓰 서비스를 제공하는 프로바이더별로 enum으로 처리. 나는 페이스북이랑, 네이버를 사용하지 않을거라 이렇게 만 했다.
ㅇ RoleType
@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, ADMIN, GUEST 로설정.
ㅇ UserPrincipal
@Getter
@Setter
@AllArgsConstructor
@RequiredArgsConstructor
public class UserPrincipal implements OAuth2User, UserDetails, OidcUser {
private final Long userId;
private final String id;
private final String password;
private final ProviderType providerType;
private final RoleType roleType;
private final LocalDate birth;
private final String emailVerifiedYn;
private final Collection<GrantedAuthority> authorities;
private Map<String, Object> attributes;
@Override
public Map<String, Object> getAttributes() {
return attributes;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return authorities;
}
@Override
public String getName() {
return id;
}
@Override
public String getUsername() {
return id;
}
@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;
}
public static UserPrincipal create(User user) {
return new UserPrincipal(
user.getUserId(),
user.getId(),
user.getPassword(),
user.getProviderType(),
RoleType.USER,
user.getBirth(),
user.getEmailVerifiedYn(),
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;
}
}
이건 하나도 안건드렸다. 이 클래스로 컨트롤러에서 인증된 사용자 정보를 불러올때 사용한다. implements 한 OidcUser는 무슨 역할인지 궁금해 찾아보니, Spring Security에서 제공하는 인터페이스중 하나고, OpenIdConnect 프로토콜을 사용해 사용자 정보를 표현하기 위한 인터페이스라고 한다. 그런데 얘로 오버라이드 하는 메서드들(getClaims, getUserInfo, getIdToken)은 다 null을 반환하는데 뭐 필요하다면 나중에 사용하도록 이렇게 해놓은거같다. 선언되는 필드들은 본인 프로젝트에 맞게 맞춰서 추가하거나 빼면된다.
컨트롤러
ㅇ AuthController
@RestController
@RequestMapping("/api/v1/auth")
@RequiredArgsConstructor
public class AuthController {
private final UserRepository userRepository;
private final AppProperties appProperties;
private final AuthTokenProvider tokenProvider;
private final AuthenticationManager authenticationManager;
private final UserRefreshTokenRepository userRefreshTokenRepository;
private final PasswordEncoder passwordEncoder;
private final static long THREE_DAYS_MSEC = 259200000;
private final static String REFRESH_TOKEN = "refresh_token";
/*
현재 로그인 방식
*/
@PostMapping("/login")
public ApiResponse login(
HttpServletRequest request,
HttpServletResponse response,
@RequestBody AuthReqModel authReqModel
) {
Optional<User> userOptional = userRepository.findById(authReqModel.getId());
//email 인증 안된경우 api 호출 불가능
if (userOptional.isEmpty() || !"Y".equals(userOptional.get().getEmailVerifiedYn())) {
return ApiResponse.unAuthorized();
}
User user = userOptional.get();
String enteredPassword = authReqModel.getPassword();
//패스워드 인코더를 통해 입력받은 패스워드와 유저의 패스워드를 같은지 비교
if(passwordEncoder.matches(enteredPassword, user.getPassword())) {
//사용자가 제공한 아이디와 비밀번호를 사용해 Spring Security의 AuthenticationManager를 통해
//사용자를 인증함. 인증이 성공하면 Authentication 객체가 생성된다.
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 ApiResponse.success("token", accessToken.getToken());
}
else{
return ApiResponse.unAuthorized();
}
}
/*
사용자 액세스 토큰을 반환하는 api
*/
@GetMapping("/refresh")
public ApiResponse refreshToken (HttpServletRequest request, HttpServletResponse response) {
// access token 확인
String accessToken = HeaderUtil.getAccessToken(request);
AuthToken authToken = tokenProvider.convertAuthToken(accessToken);
if (!authToken.validate()) {
return ApiResponse.invalidAccessToken();
}
// 만료된 access token 인지 확인
Claims claims = authToken.getExpiredTokenClaims();
if (claims == null) {
return ApiResponse.notExpiredTokenYet();
}
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()) {
return ApiResponse.invalidRefreshToken();
}
// userId refresh token 으로 DB 확인
UserRefreshToken userRefreshToken = userRefreshTokenRepository.findByUserIdAndRefreshToken(userId, refreshToken);
if (userRefreshToken == null) {
return ApiResponse.invalidRefreshToken();
}
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 ApiResponse.success("token", newAccessToken.getToken());
}
}
여긴 크게 두가지 api 가 있다. 로그인과, refresh token을 재발급하는 api 이다.
먼저 로그인 로직을 보자. 리퀘스트바디로 authReqModel 을 받아, id 와 password를 requestbody에 넣어줘야 api를 호출할 수 있다. 우리 프로젝트에서는 이메일인증을 거쳐야만 로그인 할 수 있기 때문에, 이메일인증이 된지를 먼저 검증하고, 입력받은 패스워드가 인코딩되지 않았으므로 인코딩해서, 인코딩되어 저장된 패스워드와 일치하면 authentication 객체를 만든다. 그 외에 코드에대한 설명은 주석으로 설명해놨으니 확인바란다.
예외
ㅇ OAuthProviderMissMatchException
public class OAuthProviderMissMatchException extends RuntimeException {
public OAuthProviderMissMatchException(String message) {
super(message);
}
}
오어쓰 프로바이더타입이 안맞을경우 날리는 예외
ㅇ RestAuthenticationEntryPoint
@Slf4j
public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint {
//사용자의 인증이 되지 않았을때 호출됨.
@Override
public void commence(
HttpServletRequest request,
HttpServletResponse response,
AuthenticationException authException
) throws IOException, ServletException, java.io.IOException {
//스택 트레이스 출력해줌
authException.printStackTrace();
//인증 실패에대한 로그 기록
log.info("Responding with unauthorized error. Message := {}", authException.getMessage());
response.sendError(
HttpServletResponse.SC_UNAUTHORIZED,
authException.getLocalizedMessage()
);
}
}
사용자의 인증이 실패 했을 때 어떻게 응답해야하는지를 정의해놓음.
핸들러
ㅇ OAuth2AuthenticationFailureHandler
@Component
@RequiredArgsConstructor
public class OAuth2AuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler {
private final OAuth2AuthorizationRequestBasedOnCookieRepository authorizationRequestRepository;
//이 메서드는 사용자의 요청이 OAuth 2.0 기반의 인증에서 실패했을 때 호출됨.
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException, IOException {
//클라이언트의 요청에서 리디렉션할 대상 URL을 가져옴.
String targetUrl = CookieUtil.getCookie(request, REDIRECT_URI_PARAM_COOKIE_NAME)
.map(Cookie::getValue)
.orElse(("/"));
//AuthenticationException 객체에 대한 예외 스택 트레이스를 출력함.
exception.printStackTrace();
//실패한 인증에 대한 로컬라이즈된 메시지를 포함한 에러 파라미터를 대상 URL에 추가함.
targetUrl = UriComponentsBuilder.fromUriString(targetUrl)
.queryParam("error", exception.getLocalizedMessage())
.build().toUriString();
OAuth 2.0 플로우에서 사용되는 인증 요청과 관련된 쿠키를 제거함.
authorizationRequestRepository.removeAuthorizationRequestCookies(request, response);
//최종적으로 리디렉션할 대상 URL로 클라이언트를 리디렉션함.
getRedirectStrategy().sendRedirect(request, response, targetUrl);
}
}
오어쓰의 인증이 실패했을때 어떻게 처리할지를 정의함.
ㅇ OAuth2AuthenticationSuccessHandler
@Component
@RequiredArgsConstructor
public class OAuth2AuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
private final AuthTokenProvider tokenProvider;
private final AppProperties appProperties;
private final UserRefreshTokenRepository userRefreshTokenRepository;
private final OAuth2AuthorizationRequestBasedOnCookieRepository authorizationRequestRepository;
//이 메서드는 사용자의 요청이 OAuth 2.0 기반의 인증에서 성공했을 때 호출됨.
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException, IOException {
//어떤 URL로 리디렉션할지를 결정하는 메서드 determineTargetUrl를 호출하여 대상 URL을 결정함.
String targetUrl = determineTargetUrl(request, response, authentication);
//응답이 이미 커밋된 경우, 리디렉션할 수 없으므로 예외 처리
if (response.isCommitted()) {
logger.debug("Response has already been committed. Unable to redirect to " + targetUrl);
return;
}
//사용자의 인증 관련 속성을 지우고, OAuth 2.0 관련 쿠키를 제거
clearAuthenticationAttributes(request, response);
//최종적으로 리디렉션할 대상 URL로 클라이언트를 리디렉션
getRedirectStrategy().sendRedirect(request, response, targetUrl);
}
//인증 성공 후 어떤 URL로 리디렉션할지를 결정함.
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();
}
//사용자의 인증 관련 속성을 지우고, OAuth 2.0 관련 쿠키를 제거
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;
}
//클라이언트의 리디렉션 URI가 허용된 URI 목록에 있는지 확인하는 메서드
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;
});
}
}
오어쓰의 인증이 성공했을때 어떻게 처리할지를 정의함.
ㅇ TokenAccessDeniedHandler
@Component
@RequiredArgsConstructor
public class TokenAccessDeniedHandler implements AccessDeniedHandler {
private final HandlerExceptionResolver handlerExceptionResolver;
//사용자가 요청에 대한 접근 권한이 없을 때 호출됨.
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException {
//response.sendError(HttpServletResponse.SC_FORBIDDEN, accessDeniedException.getMessage());
handlerExceptionResolver.resolveException(request, response, null, accessDeniedException);
}
}
사용자가 요청에 대한 접근 권한이 없을 때 어떻게 처리할지를 정의하는 역할을 함.
유저인포
ㅇ OAuth2UserInfo
public abstract class OAuth2UserInfo {
protected Map<String, Object> attributes;
public OAuth2UserInfo(Map<String, Object> attributes) {
this.attributes = attributes;
}
public Map<String, Object> getAttributes() {
return attributes;
}
public abstract String getId();
public abstract String getName();
public abstract String getEmail();
public abstract LocalDate getBirth();
public abstract Boolean getPushAgree();
public abstract String getImageUrl();
}
Oauth2 유저의 속성들을 abstract class로 정리해놓음. 그리고 각 프로바이더별로 userInfo를 이 클래스를 상속받아 완성시킴.
ㅇ OAuth2UserInfoFactory
public class OAuth2UserInfoFactory {
public static OAuth2UserInfo getOAuth2UserInfo(ProviderType providerType, Map<String, Object> attributes) {
switch (providerType) {
case GOOGLE: return new GoogleOAuth2UserInfo(attributes);
// case FACEBOOK: return new FacebookOAuth2UserInfo(attributes);
// case NAVER: return new NaverOAuth2UserInfo(attributes);
case KAKAO: return new KakaoOAuth2UserInfo(attributes);
default: throw new IllegalArgumentException("Invalid Provider Type.");
}
}
}
각 프로바이더별 알맞는 유저인포를 반환함.
ㅇ GoogleOAuth2UserInfo
public class GoogleOAuth2UserInfo extends OAuth2UserInfo {
public GoogleOAuth2UserInfo(Map<String, Object> attributes) {
super(attributes);
}
@Override
public String getId() {
return (String) attributes.get("sub");
}
@Override
public String getName() {
return (String) attributes.get("name");
}
@Override
public String getEmail() {
return (String) attributes.get("email");
}
@Override
public LocalDate getBirth() {
return null;
}
@Override
public Boolean getPushAgree() {
return null;
}
@Override
public String getImageUrl() {
return (String) attributes.get("picture");
}
}
Oauth2UserInfo를 확장해 완성
ㅇ KakaoOAuth2UserInfo
public class KakaoOAuth2UserInfo extends OAuth2UserInfo {
public KakaoOAuth2UserInfo(Map<String, Object> attributes) {
super(attributes);
}
@Override
public String getId() {
return attributes.get("id").toString();
}
@Override
public String getName() {
Map<String, Object> properties = (Map<String, Object>) attributes.get("properties");
if (properties == null) {
return null;
}
return (String) properties.get("nickname");
}
@Override
public String getEmail() {
return (String) attributes.get("account_email");
}
@Override
public LocalDate getBirth() {
return null;
}
@Override
public Boolean getPushAgree() {
return null;
}
@Override
public String getImageUrl() {
Map<String, Object> properties = (Map<String, Object>) attributes.get("properties");
if (properties == null) {
return null;
}
return (String) properties.get("thumbnail_image");
}
}
Oauth2UserInfo를 확장해 완성
레포지토리
ㅇ OAuth2AuthorizationRequestBasedOnCookieRepository
public class OAuth2AuthorizationRequestBasedOnCookieRepository implements AuthorizationRequestRepository<OAuth2AuthorizationRequest> {
public final static String OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME = "oauth2_auth_request";
public final static String REDIRECT_URI_PARAM_COOKIE_NAME = "redirect_uri";
public final static String REFRESH_TOKEN = "refresh_token";
private final static int cookieExpireSeconds = 180;
//현재 요청에서 OAuth 2.0 인가 요청을 로드
@Override
public OAuth2AuthorizationRequest loadAuthorizationRequest(HttpServletRequest request) {
return CookieUtil.getCookie(request, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME)
.map(cookie -> CookieUtil.deserialize(cookie, OAuth2AuthorizationRequest.class))
.orElse(null);
}
//OAuth 2.0 인가 요청을 저장
@Override
public void saveAuthorizationRequest(OAuth2AuthorizationRequest authorizationRequest, HttpServletRequest request, HttpServletResponse response) {
if (authorizationRequest == null) {
CookieUtil.deleteCookie(request, response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME);
CookieUtil.deleteCookie(request, response, REDIRECT_URI_PARAM_COOKIE_NAME);
CookieUtil.deleteCookie(request, response, REFRESH_TOKEN);
return;
}
CookieUtil.addCookie(response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME, CookieUtil.serialize(authorizationRequest), cookieExpireSeconds);
String redirectUriAfterLogin = request.getParameter(REDIRECT_URI_PARAM_COOKIE_NAME);
if (StringUtils.isNotBlank(redirectUriAfterLogin)) {
CookieUtil.addCookie(response, REDIRECT_URI_PARAM_COOKIE_NAME, redirectUriAfterLogin, cookieExpireSeconds);
}
}
//저장된 OAuth 2.0 인가 요청을 제거
@Override
public OAuth2AuthorizationRequest removeAuthorizationRequest(HttpServletRequest request) {
return this.loadAuthorizationRequest(request);
}
//저장된 OAuth 2.0 인가 요청을 제거 위의메서드랑 같은역할. 파라미터가 하나 더 있음.
@Override
public OAuth2AuthorizationRequest removeAuthorizationRequest(HttpServletRequest request, HttpServletResponse response) {
return this.loadAuthorizationRequest(request);
}
//저장된 OAuth 2.0 인가 요청과 관련된 쿠키를 모두 삭제
public void removeAuthorizationRequestCookies(HttpServletRequest request, HttpServletResponse response) {
CookieUtil.deleteCookie(request, response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME);
CookieUtil.deleteCookie(request, response, REDIRECT_URI_PARAM_COOKIE_NAME);
CookieUtil.deleteCookie(request, response, REFRESH_TOKEN);
}
}
OAuth 2.0 인증 요청을 쿠키를 사용하여 저장하고 로드하는 데 사용되는 Spring Security의 AuthorizationRequestRepository 인터페이스를 구현한 클래스다. 이 클래스는 OAuth 2.0 인증 프로세스의 중요한 부분 중 하나인 OAuth 2.0 인가 요청 관리를 담당함.
서비스
ㅇ CustomOAuth2UserService
@Service
@RequiredArgsConstructor
public class CustomOAuth2UserService extends DefaultOAuth2UserService {
private final UserRepository userRepository;
//OAuth 2.0 사용자 정보를 로드
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
OAuth2User user = super.loadUser(userRequest);
try {
return this.process(userRequest, user);
} catch (Exception ex) {
ex.printStackTrace();
throw new InternalAuthenticationServiceException(ex.getMessage(), ex.getCause());
}
}
//OAuth 2.0 사용자 정보를 처리하는 메서드
private OAuth2User process(OAuth2UserRequest userRequest, OAuth2User user) {
ProviderType providerType = ProviderType.valueOf(userRequest.getClientRegistration().getRegistrationId().toUpperCase());
OAuth2UserInfo userInfo = OAuth2UserInfoFactory.getOAuth2UserInfo(providerType, user.getAttributes());
Optional<User> byEmail = userRepository.findByEmail(userInfo.getEmail());
User savedUser = byEmail.orElse(null);
if (savedUser != null) {
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 {
savedUser = createUser(userInfo, providerType);
}
return UserPrincipal.create(savedUser, user.getAttributes());
}
//OAuth 2.0 사용자 정보를 기반으로 새로운 사용자를 생성
private User createUser(OAuth2UserInfo userInfo, ProviderType providerType) {
LocalDateTime now = LocalDateTime.now();
User user = new User(
null,
userInfo.getId(),
userInfo.getEmail(),
"Y",
null,
null,
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;
}
}
OAuth 2.0 사용자 정보를 로드하고 처리하는 데 사용되는 사용자 정의 서비스
ㅇ CustomUserDetailsService
@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
주어진 사용자 ID를 사용하여 사용자 정보를 데이터베이스에서 조회
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userRepository.findUserById(username);
if (user == null) {
throw new UsernameNotFoundException("Can not find username.");
}
return UserPrincipal.create(user);
}
}
사용자의 이름(또는 ID)를 기반으로 사용자 정보를 로드하고 Spring Security에서 필요한 형식의 UserDetails 객체로 반환
프로퍼티
ㅇ AppProperties
@Getter
@Component
@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;
}
//Auth 2.0 인증 관련 설정을 정의하는 내부 클래스
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;
}
}
}
구성속성을 정의. application.yml파일에서 읽어올때 사용
ㅇ CorsProperties
@Getter
@Setter
@Component
@ConfigurationProperties(prefix = "cors")
public class CorsProperties {
private String allowedOrigins;
private String allowedMethods;
private String allowedHeaders;
private Long maxAge;
}
Cross-Origin Resource Sharing (CORS) 설정을 관리하기 위한 클래스
ㅇ CookieUtil
/*
웹앱에서 쿠키관리하고 조작하는 유틸리티
*/
public class CookieUtil {
/*
주어진 요청에서 특정이름의 쿠키를 가져오는 역할
*/
public static Optional<Cookie> getCookie(HttpServletRequest request, String name) {
Cookie[] cookies = request.getCookies();
if (cookies != null && cookies.length > 0) {
for (Cookie cookie : cookies) {
if (name.equals(cookie.getName())) {
return Optional.of(cookie);
}
}
}
return Optional.empty();
}
/*
주어진 응담에 쿠키를 추가하는 역할
*/
public static void addCookie(HttpServletResponse response, String name, String value, int maxAge) {
Cookie cookie = new Cookie(name, value);
cookie.setPath("/");
cookie.setHttpOnly(true);
cookie.setMaxAge(maxAge);
response.addCookie(cookie);
}
/*
주어진 요청 및 응답에서 특정 이름의 쿠키를 삭제하는 역할
*/
public static void deleteCookie(HttpServletRequest request, HttpServletResponse response, String name) {
Cookie[] cookies = request.getCookies();
if (cookies != null && cookies.length > 0) {
for (Cookie cookie : cookies) {
if (name.equals(cookie.getName())) {
cookie.setValue("");
cookie.setPath("/");
cookie.setMaxAge(0);
response.addCookie(cookie);
}
}
}
}
/*
주어진 객체를 직렬화 하고 Base64로 인코딩해 문자열로 반환
*/
public static String serialize(Object obj) {
return Base64.getUrlEncoder()
.encodeToString(SerializationUtils.serialize(obj));
}
/*
주어진 쿠키 값을 역직렬화해 주어진 클래스형식으로 변환하여
*/
public static <T> T deserialize(Cookie cookie, Class<T> cls) {
return cls.cast(
SerializationUtils.deserialize(
Base64.getUrlDecoder().decode(cookie.getValue())
)
);
}
}
설명은 주석에!
ㅇ SecurityConfig
@EnableWebSecurity
@Configuration
@RequiredArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter {
private final CorsProperties corsProperties;
private final AppProperties appProperties;
private final AuthTokenProvider tokenProvider;
private final CustomUserDetailsService userDetailsService;
private final CustomOAuth2UserService oAuth2UserService;
private final TokenAccessDeniedHandler tokenAccessDeniedHandler;
private final UserRefreshTokenRepository userRefreshTokenRepository;
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService)
.passwordEncoder(passwordEncoder());
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.cors()
.and()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.csrf().disable()
.formLogin().disable()
.httpBasic().disable()
.exceptionHandling()
.authenticationEntryPoint(new RestAuthenticationEntryPoint())
.accessDeniedHandler(tokenAccessDeniedHandler)
.and()
.authorizeRequests()
.requestMatchers(CorsUtils::isPreFlightRequest).permitAll()
.antMatchers("/api/v1/emails/**").permitAll()
.antMatchers("/api/v1/auth/login").permitAll()
.antMatchers("/api/v1/users", "/api/v1/users/signup").permitAll()
.antMatchers("/api/**").hasAnyAuthority(RoleType.USER.getCode())
.antMatchers("/api/**/admin/**").hasAnyAuthority(RoleType.ADMIN.getCode())
.anyRequest().authenticated()
.and()
.oauth2Login()
.authorizationEndpoint()
.baseUri("/oauth2/authorization")
.authorizationRequestRepository(oAuth2AuthorizationRequestBasedOnCookieRepository())
.and()
.redirectionEndpoint()
.baseUri("/*/oauth2/code/*")
.and()
.userInfoEndpoint()
.userService(oAuth2UserService)
.and()
.successHandler(oAuth2AuthenticationSuccessHandler())
.failureHandler(oAuth2AuthenticationFailureHandler());
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;
}
}
ㅇ application.yml
logging:
level:
org.hibernate.SQL: trace
org.hibernate.type.descriptor.sql.BasicBinder: trace
spring:
profiles:
include: common
active: dev
datasource:
url: jdbc:mysql://knockknockdb.~~~~:3306/db이름
driver-class-name: com.mysql.cj.jdbc.Driver
username: admin
password: ${RDS_PW}
jpa:
database: mysql
database-platform: org.hibernate.dialect.MySQL5InnoDBDialect
hibernate:
ddl-auto: update
properties:
hibernate:
format_sql: true
highlight_sql: true
use_sql_comments: true
mail:
default-encoding: UTF-8
host: smtp.gmail.com
port: 587
username: 4othersofficial@gmail.com
password: ${SMTP_CREDENTIALS}
properties:
mail:
smtp:
auth: true
starttls:
enable: true
protocol: smtp
security:
oauth2:
client:
registration:
google:
client-id: ${GOOGLE_CLIENT_ID}
client-secret: ${GOOGLE_SECRET}
scope: email, profile
kakao:
client-id: ${KAKAO_CLIENT_ID}
client-secret: ${KAKAO_SECRET}
redirect-uri: "{baseUrl}/{action}/oauth2/code/{registrationId}"
client-authentication-method: POST
authorization-grant-type: authorization_code
scope: profile_nickname, profile_image, account_email
client-name: Kakao
provider:
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
jwt:
secret: ${JWT_SECRET_KEY}
expirationMs: 3600000
token:
expiration:
access: 86400000
refresh: 31536000000
cors:
allowed-origins: 'http://www.knockknockofficial.shop:8080'
allowed-methods: GET,POST,PUT,DELETE,OPTIONS
allowed-headers: '*'
max-age: 3600
app:
auth:
tokenSecret: ${TOKEN_SECRET}
tokenExpiry: 1800000
refreshTokenExpiry: 604800000
oauth2:
authorizedRedirectUris: http://www.knockknockofficial.shop:8080/oauth/redirect
#프론트 페이지만들어지면 여기다가 넣기
kakao의 redirect-uri는 저대로 쓰는거다. 해당하는 뭔갈 넣는게 아니었다. 난 뭘넣어야할지 엄청고민하고 넣었는데 안되길래 저렇게 해보니 됐다. 바보같다.
오늘의 포스팅은 여기서 끝!
'프로젝트 > 낙낙(KnockKnock)' 카테고리의 다른 글
Spring Security JWT 유저, Oauth에 대한 대대적인 공사-4(Oauth2 테스트)(성공) (0) | 2023.09.15 |
---|---|
이메일인증을 구현해보자(JavaMailSender) (0) | 2023.09.15 |
Spring Security JWT 유저, Oauth에 대한 대대적인 공사-2(JWT) (0) | 2023.09.13 |
Spring Security JWT 유저, Oauth에 대한 대대적인 공사-1(Local User) (0) | 2023.09.12 |
배포 자동화를 해보자-3(Github Actions) (완료) (0) | 2023.09.12 |