본문 바로가기
프로젝트/낙낙(KnockKnock)

Spring Security JWT 유저, Oauth에 대한 대대적인 공사-3(Oauth2)

by 티코딩 2023. 9. 14.

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는 저대로 쓰는거다. 해당하는 뭔갈 넣는게 아니었다. 난 뭘넣어야할지 엄청고민하고 넣었는데 안되길래 저렇게 해보니 됐다. 바보같다.

오늘의 포스팅은 여기서 끝!