[Spring Security] Spring Security, Oauth2, JWT 2. 실습

최진민·2021년 6월 27일
0
post-thumbnail

Setting

  • build.gradle 의존성

    plugins {
        id 'org.springframework.boot' version '2.5.1'
        id 'io.spring.dependency-management' version '1.0.11.RELEASE'
        id 'java'
    }
    
    group = 'me.jinmin'
    version = '0.0.1-SNAPSHOT'
    sourceCompatibility = '11'
    
    configurations {
        compileOnly {
            extendsFrom annotationProcessor
        }
    }
    
    repositories {
        mavenCentral()
    }
    
    dependencies {
        implementation 'org.springframework.boot:spring-boot-starter-data-jdbc'
        implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
        implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
        implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server'
        implementation 'org.springframework.boot:spring-boot-starter-security'
        implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
        implementation 'org.springframework.boot:spring-boot-starter-validation'
        implementation 'org.springframework.boot:spring-boot-starter-web'
        implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity5'
        implementation 'io.jsonwebtoken:jjwt:0.9.1'
        compileOnly 'org.projectlombok:lombok'
        developmentOnly 'org.springframework.boot:spring-boot-devtools'
        runtimeOnly 'com.h2database:h2'
        runtimeOnly 'com.microsoft.sqlserver:mssql-jdbc'
        runtimeOnly 'mysql:mysql-connector-java'
        annotationProcessor 'org.projectlombok:lombok'
        testImplementation 'org.springframework.boot:spring-boot-starter-test'
        testImplementation 'org.springframework.security:spring-security-test'
    }
    
    test {
        useJUnitPlatform()
    }
  • using setting

  • application.yml

    spring:
      profiles:
        include: oauth, mysql
  • application-oauth.yml

    spring:
      security:
        oauth2:
          client:
            registration:
    #          naver:
    #            clientId: [Client ID]
    #            client-secret: [Secret Key]
    #            redirect-uri: "{baseUrl}/{action}/oauth2/code/{registrationId}"
    #            authorization-grant-type: authorization_code
    #            scope:
    #              - email
    #              - profile_image
    #            client-name : Naver
    
              google:
                clientId: [ClientId]
                clientSecret: [ClientSecret]
                redirectUri: "{baseUrl}/oauth2/callback/{registrationId}"
                scope:
                  - email
                  - profile
    #
    #          facebook:
    #            clientId:
    #            clientSecret:
    #            redirectUri:
    #            scope:
    #              - email
    #              - public_profile
    #
    #          github:
    #            clientId:
    #            clientSecret:
    #            redirectUri:
    #            scope:
    #              - user:email
    #              - read:user
    
            provider:
    #          naver:
    #            authorization_uri: https://nid.naver.com/oauth2.0/authorize
    #            token_uri: https://nid.facebook.com/oauth2.0/token
    #            user_info_uri: https://openapi.naver.com/v1/nid/me
    #            user_name_attribute: response
    
              facebook:
                authorizationUri: https://www.facebook.com/v3.0/dialog/oauth
                tokenUri: https://graph.facebook.com/v3.0/oauth/access_token
                userInfoUri: https://graph.facebook.com/v3.0/me?fields=id,first_name,middle_name,last_name,name,email,verified,is_verified,picture,width(250),height(250)
    
    app:
      auth:
        tokenSecret: [Secret Token]
        tokenExpirationMsec: 864000000
      oauth2:
        authorizedRedirectUris:
          - http://localhost:8080/oauth2/redirect
    
    logging.level:
      org.hibernate.SQL: debug
    # org.hibernate.type: trace
    • 등록한 서비스의 클라이언트 ID, Secret Key, redirect Uri 등록(개인정보는 .gitignore에 저장)
    • scope : 로그인 성공 후 도메인에서 구글에 요청할 사용자 정보 (email, profile)
    • redirectUri : User가 구글에서 인증 성공 후 권한 코드를 전달할 도메인의 endPoint
  • application-mysql.yml

    spring:
      datasource:
        url: jdbc:mysql://localhost:3306/test?allowPublicKeyRetrieval=true&userSSL=false&serverTimezone=UTC&userLegacyDatetimeCode=false
        username: root
        password: [password]
        driver-class-name: com.mysql.cj.jdbc.Driver
        hikari:
          initialization-fail-timeout: 0
    
      jpa:
        hibernate:
          ddl-auto: create
        properties:
          hibernate:
            #show_sql: true
            format_sql: true
        database-platform: org.hibernate.dialect.MySQL5Dialect

AppProperties

  • application.yml 설정 바인딩

    @Getter
    @ConfigurationProperties(prefix = "app")
    public class AppProperties {
        private final Auth auth = new Auth();
        private final OAuth2 oAuth2 = new OAuth2();
    
        @Getter
        @Setter
        public static class Auth {
            private String tokenSecret;
            private long tokenExpirationMsec;
        }
    
        @Getter
        @Setter
        public static class OAuth2 {
            private List<String> authorizedRedirectUris = new ArrayList<>();
        }
    }

MainApp.

  • 메인 애플리케이션

    @SpringBootApplication
    @EnableConfigurationProperties(AppProperties.class)
    public class SecurityJwtOathApplication {
    
        public static void main(String[] args) {
            SpringApplication.run(SecurityJwtOathApplication.class, args);
        }
    
    }

CORS 설정

  • CORS(Cross-Origin Resource Sharing)은 동일한 출처가 아니어도 다른 출처에서의 자원을 요청하여 사용하도록 허용하는 구조
  • CORS는 오리진 사이의 리소스 공유를 제한하여 XSS, CSRF와 같은 해킹 공격 방지
    • XSS(Cross-Site Scripting) : 권한이 없는 사용자가 웹 사이트에 스크립트를 삽입하는 해킹
    • CSRF(Cross-Site Request Forgery) : 사용자의 의지와 상관없이 공격자가 의도한 행위를 특정 웹사이트에 요청하게 하는 해킹
@Configuration
public class WebConfig implements WebMvcConfigurer {

    private final long MAX_AGE_SECS = 3600;

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry
                .addMapping("/**") //CORS 적용할 URL 패턴
                .allowedOrigins("*") //자원 공유 오리진 지정
                .allowedMethods("GET","POST","PUT","PATCH","DELETE","OPTIONS") //요청 허용 메서드
                .allowedHeaders("*") //요청 허용 헤더
                .allowCredentials(true) //요청 허용 쿠키
                .maxAge(MAX_AGE_SECS);
    }
}

AuthProvider

  • OAuth 공급자

    public enum AuthProvider {
        local, facebook, google, github, naver;
    }

User, Role, UserRepository

  • User

    @Entity
    @Getter
    @NoArgsConstructor(access = AccessLevel.PROTECTED)
    public class User {
    
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY)
        private Long id;
        @Column(nullable = false)
        private String name;
        @Column(nullable = false)
        private String email;
        private String imageUrl;
        private Role role;
        @Column(nullable = false)
        private Boolean emailVerified = false;
        @JsonIgnore
        private String password;
    
        @NotNull
        @Enumerated(EnumType.STRING)
        private AuthProvider provider;
        private String providerId;
    
        @Builder
        public User(String name, String email, String imageUrl, Role role, Boolean emailVerified,
                    String password, AuthProvider provider, String providerId) {
            this.name = name;
            this.email = email;
            this.imageUrl = imageUrl;
            this.role = role;
            this.emailVerified = emailVerified;
            this.password = password;
            this.provider = provider;
            this.providerId = providerId;
        }
    
        public User update(String name, String imageUrl) {
            this.name = name;
            this.imageUrl = imageUrl;
            return this;
        }
    }
  • Role

    @Getter
    @RequiredArgsConstructor
    public enum Role {
    
        GUEST("ROLE_GUEST", "손님"),
        USER("ROLE_USER", "사용자"),
        ADMIN("ROLE_ADMIN", "관리자");
    
        private final String key;
        private final String title;
    }
  • UserRepository

    public interface UserRepository extends JpaRepository<User, Long> {
        Optional<User> findByEmail(String email);
    
        Boolean existsByEmail(String email);
    }

💖SecurityConfig

  • configure(HttpSecurity http)에서 antMacthers를 통해 설정

    @Configuration
    @RequiredArgsConstructor
    @EnableWebSecurity
    @EnableGlobalMethodSecurity(
            securedEnabled = true,
            jsr250Enabled = true,
            prePostEnabled = true
    )
    public class SecurityConfig extends WebSecurityConfigurerAdapter {
    
        private final CustomUserDetailsService customUserDetailsService;
        private final CustomOAuth2UserService customOAuth2UserService;
        private final OAuth2AuthenticationSuccessHandler oAuth2AuthenticationSuccessHandler;
        private final OAuth2AuthenticationFailureHandler oAuth2AuthenticationFailureHandler;
        private final HttpCookieOAuth2AuthorizationRequestRepository httpCookieOAuth2AuthorizationRequestRepository;
    
        @Bean
        public TokenAuthenticationFilter tokenAuthenticationFilter() {
            return new TokenAuthenticationFilter();
        }
    
        /**
         * JWT를 사용하면 Session에 저장하지 않고 Authorization Request를 Based64 encoded cookie에 저장
         */
        @Bean
        public HttpCookieOAuth2AuthorizationRequestRepository cookieAuthorizationRequestRepository(){
            return new HttpCookieOAuth2AuthorizationRequestRepository();
        }
    
        @Bean
        PasswordEncoder passwordEncoder(){
            return new BCryptPasswordEncoder();
        }
    
        /**
         * Authrization에 사용할 userDetailsService와 PasswordEncode 정의
         */
        @Override
        protected void configure(AuthenticationManagerBuilder builder) throws Exception {
            builder
                    .userDetailsService(customUserDetailsService)
                    .passwordEncoder(passwordEncoder());
        }
    
        /**
         * AhthenticationManager를 외부에서 사용하기 위해 @Bean 설정으로
         * Spring Security 밖으로 추출
         */
        @Bean(BeanIds.AUTHENTICATION_MANAGER)
        @Override
        public AuthenticationManager authenticationManagerBean() throws Exception {
            return super.authenticationManagerBean();
        }
    
        @Override
        protected void configure(HttpSecurity http) throws Exception {
            http
                    .cors() //cors 허용
                    .and()
                        .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) //Session 비활성화
                    .and()
                        .csrf().disable() //csrf 비활성화
                        .formLogin().disable() //로그인폼 비활성화
                        .httpBasic().disable() //기본 로그인 창 비활성화
                        .authorizeRequests()
                            .antMatchers("/").permitAll()
                            .antMatchers("/api/**").hasAnyRole(Role.GUEST.name(), Role.USER.name(), Role.ADMIN.name())
                            .antMatchers("/auth/**", "oauth2/**").permitAll()
                            .anyRequest().authenticated()
                    .and()
                        .oauth2Login()
                            .authorizationEndpoint()
                            .baseUri("/oauth2/authorization") //클라이언트 첫 로그인 URI
                            .authorizationRequestRepository(cookieAuthorizationRequestRepository())
                    .and()
                        .userInfoEndpoint()
                            .userService(customOAuth2UserService)
                    .and()
                        .successHandler(oAuth2AuthenticationSuccessHandler)
                        .failureHandler(oAuth2AuthenticationFailureHandler);
            http.addFilterBefore(tokenAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
        }
    }

UserPrincipal, CustomOAuthUserService

  • (참고) 사용자에 대한 인증 처리

    • User 데이터를 저장할 User 클래스
    • User를 DB에 저장
    • User 클래스를 Spring Security의 내장 클래스와 연결
      • UserDetails, UserDetailsService
    • SecurityConfig 클래스에 Auth 정의
  • UserDetails, UserDetailsService

    • UserDetails : Spring Security에서 User 클래스 역할

    • UserDetailsService : "에서 UserRepository 역할

  • UserPrincipal

    @Getter
    public class UserPrincipal implements OAuth2User, UserDetails {
    
        private Long id;
        private String email;
        private String password;
        private Collection<? extends GrantedAuthority> authorities;
        private Map<String, Object> attributes;
    
        public UserPrincipal(Long id, String email, String password,
                             Collection<? extends GrantedAuthority> authorities) {
            this.id = id;
            this.email = email;
            this.password = password;
            this.authorities = authorities;
        }
    
        public static UserPrincipal create(User user) {
            List<GrantedAuthority> authorities =
                    Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER"));
    
            return new UserPrincipal(
                    user.getId(),
                    user.getEmail(),
                    user.getPassword(),
                    authorities
            );
        }
    
        public static UserPrincipal create(User user, Map<String, Object> attributes) {
            UserPrincipal userPrinciple = UserPrincipal.create(user);
            userPrinciple.setAttributes(attributes);
            return userPrinciple;
        }
    
        @Override
        public String getUsername() {
            return email;
        }
    
        @Override
        public boolean isAccountNonExpired() {
            return true;
        }
    
        @Override
        public boolean isAccountNonLocked() {
            return true;
        }
    
        @Override
        public Collection<? extends GrantedAuthority> getAuthorities() {
            return authorities;
        }
    
        @Override
        public Map<String, Object> getAttributes() {
            return attributes;
        }
    
        public void setAttributes(Map<String, Object> attributes) {
            this.attributes = attributes;
        }
    
        @Override
        public boolean isCredentialsNonExpired() {
            return true;
        }
    
        @Override
        public boolean isEnabled() {
            return true;
        }
    
        @Override
        public String getName() {
            return String.valueOf(id);
        }
    }
    • User를 생성자로 전달받아 Spring Security에 User 정보 전달
  • CustomOAuth2UserService

    @RequiredArgsConstructor
    @Service
    public class CustomOAuth2UserService extends DefaultOAuth2UserService {
    
        private final UserRepository userRepository;
    
        @Override
        public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
            OAuth2User oAuth2User = super.loadUser(userRequest);
    
            try {
                return processOAuthUser(userRequest, oAuth2User);
            } catch (AuthenticationException e) {
                throw e;
            } catch (Exception e) {
                throw new InternalAuthenticationServiceException(e.getMessage(), e.getCause());
            }
        }
    
        /**
         * 사용자 정보 추출
         */
        private OAuth2User processOAuthUser(OAuth2UserRequest userRequest, OAuth2User oAuth2User) {
            OAuth2UserInfo oAuth2UserInfo = OAuth2UserInfoFactory
                    .getOAuth2UserInfo(userRequest.getClientRegistration().getRegistrationId(), oAuth2User.getAttributes());
    
            if (StringUtils.isEmpty(oAuth2UserInfo.getEmail())) {
                throw new OAuth2AuthenticationProcessingException("OAuth2 공급자(구글, 네이버 등)에서 이메일을 찾을 수 없습니다.");
            }
    
            Optional<User> userOptional = userRepository.findByEmail(oAuth2UserInfo.getEmail());
            User user;
    
            if (userOptional.isPresent()) {
                user = userOptional.get();
    
                if (!user.getProvider().equals(AuthProvider.valueOf(userRequest.getClientRegistration().getRegistrationId()))) {
                    throw new OAuth2AuthenticationProcessingException(user.getProvider() + "계정을 사용하기 위해서 로그인이 필요합니다.");
                }
                user = updateExistingUser(user, oAuth2UserInfo);
    
            } else {
                user = registerNewUser(userRequest, oAuth2UserInfo);
            }
            return UserPrincipal.create(user);
        }
    
        //DB에 없을 때, 등록
        private User registerNewUser(OAuth2UserRequest userRequest, OAuth2UserInfo oAuth2UserInfo) {
            return userRepository.save(
                    User.builder()
                            .name(oAuth2UserInfo.getName())
                            .email(oAuth2UserInfo.getEmail())
                            .imageUrl(oAuth2UserInfo.getImageUrl())
                            .provider(AuthProvider.valueOf(userRequest.getClientRegistration().getRegistrationId()))
                            .providerId(oAuth2UserInfo.getId())
                            .build()
            );
        }
    
        //DB에 없을 때, 수정
        private User updateExistingUser(User existingUser, OAuth2UserInfo oAuth2UserInfo) {
            return userRepository.save(
                    existingUser.update(oAuth2UserInfo.getName(), oAuth2UserInfo.getImageUrl()));
        }
    }
    • UserPrincipalDetailsService 클래스 역할 = User 정보를 가져오는 역할
    • 가져온 User의 정보는 UserPrincipal로 변경해 Spring Security로 전달
    • OAuth2 공급자로부터 Access Token을 받은 이후 호출
      • If, 동일한 이메일이 DB에 존재하지 않을 경우 사용자 정보를 등록
      • 존재하면 사용자 정보 업데이트

TokenProvider, TokenAuthenticationFilter

  • TokenProvider

    @Service
    public class TokenProvider {
        private static final Logger logger = LoggerFactory.getLogger(TokenProvider.class);
    
        private AppProperties appProperties;
    
        public TokenProvider(AppProperties appProperties) {
            this.appProperties = appProperties;
        }
    
        public String createToken(Authentication authentication) {
            UserPrincipal userPrincipal = (UserPrincipal) authentication.getPrincipal();
    
            Date now = new Date();
            Date expiryDate = new Date(now.getTime() + appProperties.getAuth().getTokenExpirationMsec());
    
            return Jwts.builder()
                    .setSubject(Long.toString(userPrincipal.getId()))
                    .setIssuedAt(new Date())
                    .setExpiration(expiryDate)
                    .signWith(SignatureAlgorithm.HS512, appProperties.getAuth().getTokenSecret())
                    .compact();
        }
    
        public Long getUserIdFromToken(String token) {
            Claims claims = Jwts.parser()
                    .setSigningKey(appProperties.getAuth().getTokenSecret())
                    .parseClaimsJws(token)
                    .getBody();
    
            return Long.parseLong(claims.getSubject());
        }
    
        public boolean validateToken(String authToken) {
            try {
                Jwts.parser().setSigningKey(appProperties.getAuth().getTokenSecret()).parseClaimsJws(authToken);
                return true;
            } catch (SignatureException e) {
                logger.error("유효하지 않은 JWT 서명");
            } catch (MalformedJwtException e) {
                logger.error("유효하지 않은 JWT 토큰");
            } catch (ExpiredJwtException e) {
                logger.error("만료된 JWT 토큰");
            } catch (UnsupportedJwtException e) {
                logger.error("지원하지 않는 JWT");
            } catch (IllegalArgumentException e) {
                logger.error("비어있는 JWT");
            }
    
            return false;
        }
    }
    • 💖유효한 JWT(JSon Web Token) 발급
  • TokenAuthenticationFilter

    public class TokenAuthenticationFilter extends OncePerRequestFilter {
    
        @Autowired
        private TokenProvider tokenProvider;
    
        @Autowired
        private CustomUserDetailsService customUserDetailsService;
    
        private static final Logger logger = LoggerFactory.getLogger(TokenAuthenticationFilter.class);
    
        @Override
        protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
                                        FilterChain filterChain) throws ServletException, IOException {
            try {
                String jwt = getJwtFromRequest(request);
    
                if (StringUtils.hasText(jwt) && tokenProvider.validateToken(jwt)) {
                    Long userId = tokenProvider.getUserIdFromToken(jwt);
    
                    UserDetails userDetails = customUserDetailsService.loadUserById(userId);
                    UsernamePasswordAuthenticationToken authentication =
                            new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
                    authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
    
                    SecurityContextHolder.getContext().setAuthentication(authentication);
                }
            } catch (Exception e) {
                logger.error("Security Context에서 사용자 인증을 설정할 수 없습니다.", e);
            }
    
            filterChain.doFilter(request, response);
        }
    
        private String getJwtFromRequest(HttpServletRequest request) {
            String bearerToken = request.getHeader("Authorization");
    
            if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
                return bearerToken.substring(7, bearerToken.length());
            }
            return null;
        }
    }
    • 요청으로부터 전달받은 JWT를 검증
    • 토큰 유효성 검사 후 토큰에 있는 사용자 ID를 가져온다.
    • ID를 통해 사용자의 모든 정보를 가져오고 UsernamePasswordAuthenticationToken을 만들어 인증 과정을 거친다.
  • UsernamePasswordAuthenticationToken

    public class UsernamePasswordAuthenticationToken extends AbstractAuthenticationToken {
    
        private final Object principal;
        private Object credentials;
    
        public UsernamePasswordAuthenticationToken(Object principal, Object credentials) {
            super(null);
            this.principal = principal;
            this.credentials = credentials;
            super.setAuthenticated(false);
        }
    
        public UsernamePasswordAuthenticationToken(Object principal, Object credentials, Collection<? extends GrantedAuthority> authorities) {
            super(authorities);
            this.principal = principal;
            this.credentials = credentials;
            super.setAuthenticated(true);
        }
    
        @Override
        public Collection<GrantedAuthority> getAuthorities() {
            return super.getAuthorities();
        }
    
        @Override
        public String getName() {
            return super.getName();
        }
    
        @Override
        public boolean isAuthenticated() {
            return super.isAuthenticated();
        }
    
        @Override
        public void setAuthenticated(boolean authenticated) {
            super.setAuthenticated(authenticated);
        }
    
        @Override
        public Object getDetails() {
            return super.getDetails();
        }
    
        @Override
        public void setDetails(Object details) {
            super.setDetails(details);
        }
    
        @Override
        public void eraseCredentials() {
            super.eraseCredentials();
        }
    
        @Override
        public boolean equals(Object obj) {
            return super.equals(obj);
        }
    
        @Override
        public int hashCode() {
            return super.hashCode();
        }
    
        @Override
        public String toString() {
            return super.toString();
        }
    
        @Override
        public boolean implies(Subject subject) {
            return false;
        }
    
        @Override
        public Object getCredentials() {
            return null;
        }
    
        @Override
        public Object getPrincipal() {
            return null;
        }
    }
    • username과 password 조합으로 토큰 객체 생성
    • 첫 번째 생성자 : username + password로 받아 만들어진 인증 전 객체 → setAuthenticatedfalse
    • 두 번째 생성자 : username + password + authorities로 받아 만들어진 인증 후 객체 → setAuthenticatedtrue

HttpCookieOAuth2AuthorizationRequestRepository

@Component
public class HttpCookieOAuth2AuthorizationRequestRepository implements
        AuthorizationRequestRepository<OAuth2AuthorizationRequest> {

    public static final String OAUTH2_AUTHORIZATION_REQUEST_NAME = "oauth2_auth_request";
    public static final String REDIRECT_URI_PARAM_COOKIE_NAME = "redirect_uri";
    private static final int cookieExpireSeconds = 180;

    @Override
    public OAuth2AuthorizationRequest loadAuthorizationRequest(HttpServletRequest request) {
        return CookieUtils.getCookie(request, OAUTH2_AUTHORIZATION_REQUEST_NAME)
                .map(cookie -> CookieUtils.deserialize(cookie, OAuth2AuthorizationRequest.class))
                .orElse(null);
    }

    @Override
    public void saveAuthorizationRequest(OAuth2AuthorizationRequest authorizationRequest,
                                         HttpServletRequest request, HttpServletResponse response) {
        if (authorizationRequest == null) {
            CookieUtils.deleteCookie(request, response, OAUTH2_AUTHORIZATION_REQUEST_NAME);
            CookieUtils.deleteCookie(request, response, REDIRECT_URI_PARAM_COOKIE_NAME);
            return;
        }
        
        CookieUtils.addCookie(response, OAUTH2_AUTHORIZATION_REQUEST_NAME,
                CookieUtils.serialize(authorizationRequest), cookieExpireSeconds);

        String redirectUriAfterLogin = request.getParameter(REDIRECT_URI_PARAM_COOKIE_NAME);
        if (StringUtils.isNotBlank(redirectUriAfterLogin)) {
            CookieUtils.addCookie(response, REDIRECT_URI_PARAM_COOKIE_NAME, redirectUriAfterLogin, cookieExpireSeconds);
        }
    }

    @Override
    public OAuth2AuthorizationRequest removeAuthorizationRequest(HttpServletRequest request) {
        return this.loadAuthorizationRequest(request);
    }

    public void removeAuthorizationRequestCookies(HttpServletRequest request,
                                                                 HttpServletResponse response) {
        CookieUtils.deleteCookie(request, response, OAUTH2_AUTHORIZATION_REQUEST_NAME);
        CookieUtils.deleteCookie(request, response, REDIRECT_URI_PARAM_COOKIE_NAME);
    }
}
  • 인증 요청을 쿠키에 저장하고 검색하는 기능
  • OAuth2 프로토콜은 CSRF 공격을 방지하기 위해 STATE 매개 변수 사용을 권장한다.
  • 인증 과정 중 은 인증 요청에 매개변수를 담고, OAuth2 공급자는 OAuth2 콜백에서 변경되지 않은 해당 매개 변수를 반환
  • 앱은 OAuth2 공급자로부터 반환된 매개 변수의 값을 비교
    • 일치하지 않으면 인증 요청 거부
    • 🧨즉, 앱이 상태 매개 변수를 저장하고 후에 OAuth2 공급자에서 반환된 상태와 비교할 수 있어야 하고, 이를 위해 상태redirect_uri를 저장하도록 설정

CookieUtils

public class CookieUtils {
    public static Optional<Cookie> getCookie(HttpServletRequest request, String name) {
        Cookie[] cookies = request.getCookies();

        if (cookies != null && cookies.length > 0) {
            for (Cookie cookie : cookies) {
                if (cookie.getName().equals(name)) {
                    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 (cookie.getName().equals(name)) {
                    cookie.setPath("/");
                    cookie.setValue("");
                    cookie.setMaxAge(0);
                    response.addCookie(cookie);
                }
            }
        }
    }

    public static String serialize(Object obj) {
        return Base64.getUrlEncoder()
                .encodeToString(SerializationUtils.serialize(obj));
    }

    public static <T> T deserialize(Cookie cookie, Class<T> t) {
        return t.cast(SerializationUtils.deserialize(
                Base64.getUrlDecoder().decode(cookie.getValue())
        ));
    }
}
  • HttpCookieOAuth2AuthorizationRequestRepository에서 사용될 쿠키의 기능 클래스

OAuth2UserInfo

  • 추상 클래스를 각각의 서비스(Facebook, Google, Guthub 등)에 맞게 구현

    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 String getImageUrl();
    }
  • Facebook

    public class FacebookOAuth2UserInfo extends OAuth2UserInfo {
        public FacebookOAuth2UserInfo(Map<String, Object> attributes) {
            super(attributes);
        }
    
        @Override
        public String getId() {
            return (String) attributes.get("id");
        }
    
        @Override
        public String getName() {
            return (String) attributes.get("name");
        }
    
        @Override
        public String getEmail() {
            return (String) attributes.get("email");
        }
    
        @Override
        public String getImageUrl() {
            if (attributes.containsKey("picture")) {
                Map<String, Object> pictureObj = (Map<String, Object>) attributes.get("picture");
                if (pictureObj.containsKey("data")) {
                    Map<String, Object> dataObj = (Map<String, Object>) attributes.get("data");
                    if (dataObj.containsKey("url")) {
                        return (String) dataObj.get("url");
                    }
                }
            }
    
            return null;
        }
    }
  • Google

    public class GoogleOAuth2UserInfo extends OAuth2UserInfo {
        public GoogleOAuth2UserInfo(Map<String, Object> attributes) {
            super(attributes);
        }
    
        @Override
        public String getId() {
            return (String) attributes.get("id");
        }
    
        @Override
        public String getName() {
            return (String) attributes.get("name");
        }
    
        @Override
        public String getEmail() {
            return (String) attributes.get("email");
        }
    
        @Override
        public String getImageUrl() {
            return (String) attributes.get("picture");
        }
    }
  • Github

    public class GithubOAuth2UserInfo extends OAuth2UserInfo {
        public GithubOAuth2UserInfo(Map<String, Object> attributes) {
            super(attributes);
        }
    
        @Override
        public String getId() {
            return ((Integer) attributes.get("id")).toString();
        }
    
        @Override
        public String getName() {
            return (String) attributes.get("name");
        }
    
        @Override
        public String getEmail() {
            return (String) attributes.get("email");
        }
    
        @Override
        public String getImageUrl() {
            return (String) attributes.get("avatar_url");
        }
    }
  • Naver

    public class NaverOAuth2UserInfo extends OAuth2UserInfo {
        public NaverOAuth2UserInfo(Map<String, Object> attributes) {
            super(attributes);
        }
    
        @Override
        public String getId() {
            return (String) attributes.get("id");
        }
    
        @Override
        public String getName() {
            return (String) attributes.get("name");
        }
    
        @Override
        public String getEmail() {
            return (String) attributes.get("email");
        }
    
        @Override
        public String getImageUrl() {
            return (String) attributes.get("profile_image");
        }
    }
  • OAuth2UserInfoFactory

    public class OAuth2UserInfoFactory {
    
        public static OAuth2UserInfo getOAuth2UserInfo(String registrationId, Map<String, Object> attributes) {
            if (registrationId.equalsIgnoreCase(AuthProvider.google.toString())) {
                return new GoogleOAuth2UserInfo(attributes);
            } else if (registrationId.equalsIgnoreCase(AuthProvider.facebook.toString())) {
                return new FacebookOAuth2UserInfo(attributes);
            } else if (registrationId.equalsIgnoreCase(AuthProvider.github.toString())) {
                return new GithubOAuth2UserInfo(attributes);
            } else if (registrationId.equalsIgnoreCase(AuthProvider.naver.toString())) {
                return new NaverOAuth2UserInfo(attributes);
            } else {
                throw new OAuth2AuthenticationProcessingException(registrationId + " 로그인은 지원하지 않습니다.");
            }
        }
    }

OAuth2AuthenticationSuccessHandler

  • 인증에 성공하면 Spring Security는 SercuritConfig에 설정된 성공메서드 호출

    @Component
    public class OAuth2AuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
    
        private TokenProvider tokenProvider;
        private AppProperties appProperties;
        private HttpCookieOAuth2AuthorizationRequestRepository httpCookieOAuth2AuthorizationRequestRepository;
    
        @Autowired
        public OAuth2AuthenticationSuccessHandler(TokenProvider tokenProvider,
                                                  AppProperties appProperties,
                                                  HttpCookieOAuth2AuthorizationRequestRepository httpCookieOAuth2AuthorizationRequestRepository) {
            this.tokenProvider = tokenProvider;
            this.appProperties = appProperties;
            this.httpCookieOAuth2AuthorizationRequestRepository = httpCookieOAuth2AuthorizationRequestRepository;
        }
    
        @Override
        public void onAuthenticationSuccess(HttpServletRequest request,
                                            HttpServletResponse response,
                                            Authentication authentication) throws IOException, ServletException {
            String targetUrl = determineTargetUrl(request, response, authentication);
    
            if (response.isCommitted()) {
                logger.debug("응답이 이미 커밋되었습니다. " + targetUrl + "로 리다이렉션 할 수 없습니다.");
                return;
            }
    
            clearAuthenticationAttributes(request, response);
            getRedirectStrategy().sendRedirect(request, response, targetUrl);
        }
    
        protected String determineTargetUrl(HttpServletRequest request,
                                            HttpServletResponse response,
                                            Authentication authentication) {
            Optional<String> redirectUri = CookieUtils.getCookie(request, REDIRECT_URI_PARAM_COOKIE_NAME)
                    .map(Cookie::getValue);
    
            if (redirectUri.isPresent() && !isAuthorizedRedirectUri(redirectUri.get())) {
                throw new BadRequestException("승인되지 않은 리디렉션 URI가 있어 인증을 진행할 수 없습니다.");
            }
    
            String targetUrl = redirectUri.orElse(getDefaultTargetUrl());
    
            String token = tokenProvider.createToken(authentication);
    
            return UriComponentsBuilder.fromUriString(targetUrl)
                    .queryParam("token", token)
                    .build().toString();
        }
    
        protected void clearAuthenticationAttributes(HttpServletRequest request,
                                                     HttpServletResponse response) {
            super.clearAuthenticationAttributes(request);
    
            httpCookieOAuth2AuthorizationRequestRepository.removeAuthorizationRequestCookies(request, response);
        }
    
        private boolean isAuthorizedRedirectUri(String uri) {
            URI clientRedirectUri = URI.create(uri);
    
            return appProperties.getOAuth2().getAuthorizedRedirectUris()
                    .stream()
                    .anyMatch(authorizedRedirectUri -> {
                        URI authorizedUri = URI.create(authorizedRedirectUri);
                        if (authorizedUri.getHost().equalsIgnoreCase(clientRedirectUri.getHost()) &&
                                authorizedUri.getPort() == clientRedirectUri.getPort()) {
                            return true;
                        }
    
                        return false;
                    });
        }
    
    }

OAuth2AuthenticationFailureHandler

  • 인증에 실패하면 Spring Security는 SercuritConfig에 설정된 실패메서드 호출

    import static me.jinmin.security_jwt_oath.security.oauth2.HttpCookieOAuth2AuthorizationRequestRepository.REDIRECT_URI_PARAM_COOKIE_NAME;
    
    @Component
    public class OAuth2AuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler {
    
        @Autowired
        HttpCookieOAuth2AuthorizationRequestRepository httpCookieOAuth2AuthorizationRequestRepository;
    
        @Override
        public void onAuthenticationFailure(HttpServletRequest request,
                                            HttpServletResponse response,
                                            AuthenticationException exception) throws IOException, ServletException {
            String targetUrl = CookieUtils.getCookie(request, REDIRECT_URI_PARAM_COOKIE_NAME)
                    .map(Cookie::getValue)
                    .orElse(("/"));
    
            targetUrl = UriComponentsBuilder.fromUriString(targetUrl)
                    .queryParam("error", exception.getLocalizedMessage())
                    .build().toUriString();
    
            httpCookieOAuth2AuthorizationRequestRepository.removeAuthorizationRequestCookies(request, response);
            getRedirectStrategy().sendRedirect(request, response, targetUrl);
        }
    }

AuthController

  • 외부 도메인인 클라이언트가 앱 서버에 자원을 요청하기 위해 CORS를 허용해야한다.

    @RequiredArgsConstructor
    @RestController
    @RequestMapping("/auth")
    public class AuthController {
    
        private final AuthenticationManager authenticationManager;
        private final UserRepository userRepository;
        private final PasswordEncoder passwordEncoder;
        private final TokenProvider tokenProvider;
    
        @PostMapping("/login")
        public ResponseEntity<?> authenticateUser(@Valid @RequestBody LoginRequest loginRequest) {
            Authentication authentication = authenticationManager.authenticate(
                    new UsernamePasswordAuthenticationToken(
                            loginRequest.getEmail(),
                            loginRequest.getPassword()
                    )
            );
    
            SecurityContextHolder.getContext().setAuthentication(authentication);
    
            String token = tokenProvider.createToken(authentication);
    
            return ResponseEntity.ok(new AuthResponse(token));
        }
    
        @PostMapping("/signup")
        public ResponseEntity<?> registerUser(@RequestBody SignUpRequest signUpRequest) {
            if (userRepository.existsByEmail(signUpRequest.getEmail())) {
                throw new BadRequestException("해당 이메일을 이미 사용중입니다.");
            }
    
            User result = userRepository.save(User.builder()
                    .name(signUpRequest.getName())
                    .email(signUpRequest.getEmail())
                    .password(passwordEncoder.encode(signUpRequest.getPassword()))
                    .provider(AuthProvider.local)
                    .build());
    
            URI location = ServletUriComponentsBuilder
                    .fromCurrentContextPath().path("/user/me")
                    .buildAndExpand(result.getId()).toUri();
    
            return ResponseEntity.created(location)
                    .body(new ApiResponse(true, "성공적으로 계정이 생성됐습니다."));
        }
    }

@CurrentUser, UserController

  • @CurrentUser : 인증 된 사용자 주체를 컨트롤러에 삽입

    @Target({ElementType.PARAMETER, ElementType.TYPE})
    @Retention(RetentionPolicy.RUNTIME)
    @Documented
    @AuthenticationPrincipal
    public @interface CurrentUser {
    }
  • UserController

    @RequiredArgsConstructor
    @RestController
    public class UserController {
    
        private final UserRepository userRepository;
    
        @GetMapping("/user/me")
        @PreAuthorize("hasRole('USER')")
        public User getCurrentUser(@CurrentUser UserPrincipal userPrincipal) {
            return userRepository.findById(userPrincipal.getId())
                    .orElseThrow(() -> new ResourceNotFoundException("User", "id", userPrincipal.getId()));
        }
    }
  • DTO

    • LoginRequest

      @Getter
      public class LoginRequest {
      
          @NotBlank
          @Email
          private String email;
      
          @NotBlank
          private String password;
      
          @Builder
          public LoginRequest(String email, String password) {
              this.email = email;
              this.password = password;
          }
      }
    • SignUpRequest

      @Getter
      public class SignUpRequest {
      
          private String name;
          private String email;
          private String password;
      
          @Builder
          public SignUpRequest(String name, String email, String password) {
              this.name = name;
              this.email = email;
              this.password = password;
          }
      }
    • AuthResponse

      @Getter
      public class AuthResponse {
      
          private String accessToken;
          private String tokenType = "Bearer";
      
          @Builder
          public AuthResponse(String accessToken) {
              this.accessToken = accessToken;
          }
      }
    • ApiResponse

      @Getter
      public class ApiResponse {
      
          private boolean success;
          private String message;
      
          @Builder
          public ApiResponse(boolean success, String message) {
              this.success = success;
              this.message = message;
          }
      }

Exception

  • BadRequestException : RuntimeException 상속

    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public class BadRequestException extends RuntimeException {
    
        public BadRequestException(String message) {
            super(message);
        }
    
        public BadRequestException(String message, Throwable throwable) {
            super(message, throwable);
        }
    }
  • ResourceNotFoundException : RuntimeException 상속

    @Getter
    @ResponseStatus(HttpStatus.NOT_FOUND)
    public class ResourceNotFoundException extends RuntimeException {
    
        private String resourceName;
        private String fieldName;
        private Object fieldValue;
    
        public ResourceNotFoundException(String resourceName, String fieldName, Object fieldValue) {
            super(String.format("%s not found with %s : '%s'", resourceName, fieldName, fieldValue));
            this.resourceName = resourceName;
            this.fieldName = fieldName;
            this.fieldValue = fieldValue;
        }
    }
  • OAuth2AuthenticationProcessingException : AuthenticationException 상속

    public class OAuth2AuthenticationProcessingException extends AuthenticationException {
    
        public OAuth2AuthenticationProcessingException(String msg, Throwable throwable) {
            super(msg, throwable);
        }
    
        public OAuth2AuthenticationProcessingException(String msg) {
            super(msg);
        }
    }

전체 구조

profile
열심히 해보자9999

0개의 댓글