본문 바로가기

Spring

[Spring] Spring Security, JWT, OAuth 2.0

Spring Security

Spring Security는 Spring 기반의 애플리케이션의 보안(인증과 권한, 인가 등)을 담당하는 스프링 하위 프레임워크이다.

스프링 시큐리티에서 가장 핵심적인 개념은 인증(Authentication)인가(Authorization)로 사용자가 누구인지 확인하는 절차를 인증이라고 하고 인증된 사용자가 요청한 자원에 접근이 가능한지 확인하는 절차를 인가라고 한다.

Spring Security 인증과정

  1. 사용자가 로그인 정보와 함께 인증 요청 (HttpRequest)
  2. AuthenticationFilter가 요청을 가로챔. 이때 가로챈 정보를 통해 UsernamePasswordAuthenticationToken 객체 생성
  3. ProviderManager (AuthenticationManager 구현체) 에게 UsernamePasswordAuthenticationToken 객체를 전달
  4. AuthenticationProvider에 UsernamePasswordAuthenticationToken 객체를 전달
  5. 실제 DB로 부터 사용자 인증 정보를 가져오는 UserDetailsService에 사용자 정보를 전달
  6. 넘겨받은 정보를 통해 DB에서 찾은 사용자 정보인 UserDetails 객체를 생성
  7. AuthenticationProvider는 UserDetails를 넘겨받고 사용자 정보를 비교
  8. 인증이 완료되면, 사용자 정보를 담은 Authentication 객체를 반환
  9. AuthenticationFilter에 Authentication 객체가 반환
  10. Authentication 객체를 SecurityContext에 저장

OAuth

개념

OAuth는 제 3자 인증 방식으로 인터넷 사용자들이 비밀번호를 제공하지 않고 다른 웹사이트 상의 자신들의 정보에 대해 웹사이트나 애플리케이션의 접근 권한을 보여할 수 있는 공통적인 수단으로서 사용되는, 접근 위임을 위한 개방형 표준이다. 사용자가 애플리케이션에게 모든 권한을 넘기지 않고 사용자 대신 서비스를 이용할 수 있게 해주는 HTTP 기반의 보안 프로토콜이다.

주요 용어

  • Resource Owner : 웹 서비스를 이용하려는 유저, 자원(개인정보)을 소유하는 자
  • Client : 자사 또는 개인이 만든 애플리케이션 서버
  • Authorization Server : 권한을 부여해주는 서버
  • Resource Server : 사용자의 개인정보를 가지고있는 애플리케이션 회사 서버

JWT (JSON Web Token)

개념

사용자 정보를 서버에 저장하는 세션방식과 달리 사용자의 신원을 확인할 수 있는 최소 정보를 암호화 한 뒤 각 클라이언트의 쿠키에 저장했다가 권한이 필요한 요청시마다 Authorization Header에 담아 보내 인증받는 방식이다.

 

동시 접속자가 많을 때 서버 측의 부하를 낮출수 있다는 장점이 있지만 구현 복잡도가 증가하고 JWT에 담는 내용이 커질 수록 네트워크 비용이 증가한다. (사용자의 신원을 확인할 수 있는 최소 정보를 담는 이유) 

 

이미 생성된 JWT Token을 만료시킬 방법이 없기 때문에 보통 유효기간이 짧은 accessToken과 accessToken이 만료되었을때 재발급 받을 수 있는 유효기간이 긴 refreshToken을 함께 사용한다.

JWT Token 구조

  • 헤더(header) - 토큰 타입과 알고리즘을 의미. HS256 혹은 RSA를 주로 사용
  • 내용(payload) - 이름(name)과 값(value)의 쌍을 Claim이라고 하고, claims를 모아둔 객체를 의미, 일반적으로 사용자 PK나 유효기간을 포함하고 있다.
  • 서명(signature) - 헤더(header)의 인코딩 값과 내용(payload)의 인코딩 값을 합쳐 비밀 키(secret key)로 해시 함수로 처리된 결과

build.gradle

dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
	implementation 'org.springframework.boot:spring-boot-starter-security'
	implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
	implementation 'org.springframework.boot:spring-boot-starter-web'
	implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity5'
	compileOnly 'org.projectlombok:lombok'
	runtimeOnly 'com.h2database:h2'
	runtimeOnly 'com.mysql:mysql-connector-j'
	annotationProcessor 'org.projectlombok:lombok'
	testImplementation 'org.springframework.boot:spring-boot-starter-test'
	testImplementation 'org.springframework.security:spring-security-test'

	// Oauth2
	implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'

	// JsonWebToken
	implementation group: 'io.jsonwebtoken', name: 'jjwt', version: '0.9.1'
    
	// lang3
	implementation 'org.apache.commons:commons-lang3:3.12.0'

}

프로젝트에서 사용한 dependency입니다. 스프링 부트 스타터에서 Spring Web, Spring Security, Spring Data JPA, H2 Database, MySQL Driver, Lombok을 추가하였고 Oauth2와 JWT 토큰 그리고 임시 비밀번호 생성에 쓰일 lang3 dependency까지 추가하였습니다.

application.yml

spring:
  security:
    oauth2:
      client:
        registration:
          google:
            client-id: 생성된 클라이언트 아이디
            client-secret: 생성된 클라이언트 비밀번호
            scope: email
          kakao:
            client-id: REST-API키
            client-secret: 생성된 클라이언트 비밀번호
            scope: account_email, profile_nickname, profile_image
            redirect-uri: <your url>/login/oauth2/code/kakao
            authorization-grant-type: authorization_code
            client-name: kakao
            client-authentication-method: POST

        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

  datasource:
    #h2 setting
    driver-class-name: org.h2.Driver
    url: jdbc:h2:tcp://localhost/~/springsecurity
    username: sa
    password:

  jpa:
    hibernate:
      ddl-auto: update
      properties:
        hibernate:
          format_sql: true
      show-sql: true
    open-in-view: false

logging.level:
  org.hibernate.SQL: debug

server:
  port: 8081

구글 클라우드와 카카오 디벨로퍼에서 앱을 등록할 때 발급 받은 정보들을 연동합니다. CommonOAuth2Provider에서 구글과 페이스북은 기본 정보를 제공해주지만 네이버와 카카오는 제공해주지 않으므로 따로 Provider를 설정해주어야 합니다.

SecurityConfig

@Configuration
@Log4j2
@EnableWebSecurity
@EnableGlobalMethodSecurity(securedEnabled = true)
@RequiredArgsConstructor
public class SecurityConfig {

    private final ClubOAuth2UserDetailsService clubOAuth2UserDetailsService;

    private final JWTUtil jwtUtil;

    @Bean
    PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {

        http
                .httpBasic().disable()
                .csrf().disable()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .authorizeRequests()
                .antMatchers("/sample/all").permitAll()
                .antMatchers("/api/**").permitAll()
                .antMatchers(HttpMethod.GET,"/notes/**").permitAll()
                .antMatchers("/sample/member").hasRole("USER")
                .antMatchers("/notes").hasRole("User")
                .anyRequest().authenticated()
                .and()
                .oauth2Login().userInfoEndpoint().userService(clubOAuth2UserDetailsService).and().successHandler(successHandler())
                .and()
                .addFilterBefore(new JwtAuthenticationFilter(jwtUtil), UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }

    @Bean
    public ClubLoginSuccessHandler successHandler() {
        return new ClubLoginSuccessHandler(jwtUtil);
    }

}

스프링 시큐리티 설정 클래스 입니다. 스프링 부트 2.7.0 이전 까지는 WebSecurityConfigurerAdapter 라는 추상클래스를 상속받아서 구현했지만 2.7.0 이후 부터는 deprecated 되었습니다. 그렇기 때문에 configure 메서드를 오버라이드하지 않고 SecurityFilterChain을 반환하는 filterChain 메서드를 구현하여 접근 제한을 처리합니다.

 

@EnabledWebSecurity 어노테이션은 스프링 시큐리티 설정들을 활성화 시켜줍니다.

 

@EnabledGlobalMethodSecurity는 어노테이션 기반의 접근 제한을 설정할 수 있도록 하는 설정입니다. securedEnabled 속성은 예전 버전의 @Secure 어노테이션이 사용 가능한지를 정합니다.

 

PasswordEncoder는 비밀번호를 암호화하는 객체입니다. BCryptPasswordEncoder라는 클래스를 사용하며 암호화만 가능하고 복호화는 불가능합니다.

UserDetailsService

@Log4j2
@Service
@RequiredArgsConstructor
public class ClubUserDetailsService implements UserDetailsService {

    private final ClubMemberRepository clubMemberRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {

        log.info("ClubUserDetailsService loadUserByUsername " + username);

        ClubMember clubMember = clubMemberRepository.findByEmail(username)
                .orElseThrow(() -> new UsernameNotFoundException("Check Email or Social"));

        ClubAuthMemberDTO clubAuthMemberDTO = new ClubAuthMemberDTO(
                clubMember.getEmail(),
                clubMember.getPassword(),
                clubMember.isFromSocial(),
                clubMember.getRoleSet().stream()
                        .map(role -> new SimpleGrantedAuthority("ROLE_" + role.name()))
                        .collect(Collectors.toSet())
        );

        clubAuthMemberDTO.setName(clubMember.getName());

        return clubAuthMemberDTO;

    }
}

Spring Security 인증과정에서 5번 절차에 해당하는 UserDetailsService 인터페이스를 구현한 ClubUserDetailsService 입니다. 전달된 사용자 정보를 가지고 loadUserByUsername 메서드를 이용해 실제 DB로 부터 사용자 인증 정보를 가져오는 역할을 하며 사용자가 존재하지 않으면 UsernameNotFoundException으로 처리됩니다.

 

리턴 타입인 UserDetails 인터페이스는 아래와 같은 정보를 알아낼 수 있도록 구성되어 있습니다.

  • getAuthorities() - 사용자가 가지는 권한에 대한 정보
  • getPassword() - 인증을 마무리하기 위한 패스워드 정보
  • getUsername() - 인증에 필요한 아이디와 같은 정보
  • 계정 만료 여부 - 더이상 사용이 불가능한 계정인지 알 수 있는 정보
  • 계정 잠김 여부 - 현재 계정의 잠김 여부

UserDetails

@Log4j2
@Getter
@Setter
@ToString
public class ClubAuthMemberDTO extends User implements OAuth2User {

    private String email;

    private String password;

    private String name;

    private boolean fromSocial;

    private Map<String, Object> attr;

    public ClubAuthMemberDTO(String username,
                             String password,
                             boolean fromSocial,
                             Collection<? extends GrantedAuthority> authorities,
                             Map<String, Object> attr) {
                             
        this(username, password, fromSocial, authorities);
        this.attr = attr;
    }

    public ClubAuthMemberDTO(String username,
                             String password,
                             boolean fromSocial,
                             Collection<? extends GrantedAuthority> authorities) {

        super(username, password, authorities);
        this.email = username;
        this.password = password;
        this.fromSocial = fromSocial;
    }

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

UserDetails 역할을 수행할 ClubAuthMemberDTO 클래스 입니다. UserDetails 인터페이스를 구현한 User 클래스를 상속 받았고 소셜로그인에서도 사용하기 위해 OAuth2User 인터페이스를 구현하도록 하였습니다.

 

ClubAuthMemberDTO는 2개의 생성자를 가지고 있는데 두 생성자의 차이는 위 생성자는 소셜로그인에 사용할 생성자로 소셜로그인의 인증 결과를 Map 타입으로 attr 변수에 저장하고 아래 생성자는 일반로그인에서 사용할 생성자로 상속받은 부모클래스인 User 클래스의 생성자를 가져와서 사용합니다.

OAuth2UserService

@Log4j2
@Service
@RequiredArgsConstructor
public class ClubOAuth2UserDetailsService extends DefaultOAuth2UserService {

    private final ClubMemberRepository clubMemberRepository;

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {

        log.info("------------------------------");
        log.info("userRequest:" + userRequest);

        String clientName = userRequest.getClientRegistration().getClientName();

        log.info("clientName: " + clientName);
        log.info(userRequest.getAdditionalParameters());

        OAuth2User oAuth2User = super.loadUser(userRequest);

        log.info("==============================");
        oAuth2User.getAttributes().forEach((k,v) -> {
            log.info(k + " : " + v);
        });

        String email = null;

        if (clientName.equals("Google")) {
            email = oAuth2User.getAttribute("email");
        } else if (clientName.equals("Kakao")) {
            HashMap<String, String> map = oAuth2User.getAttribute("kakao_account");
            email = map.get("email");
        }

        log.info("EMAIL: " + email);

        ClubMember clubMember = saveSocialMember(email);

        ClubAuthMemberDTO clubAuthMember = new ClubAuthMemberDTO(
                clubMember.getEmail(),
                clubMember.getPassword(),
                true,
                clubMember.getRoleSet()
                        .stream()
                        .map(role -> new SimpleGrantedAuthority("ROLE_" + role.name()))
                        .collect(Collectors.toList()),
                oAuth2User.getAttributes()
        );
        clubAuthMember.setName(clubMember.getName());

        return clubAuthMember;

    }

    private ClubMember saveSocialMember(String email) {

        Optional<ClubMember> result = clubMemberRepository.findByEmail(email);

        if (result.isPresent()) {
            return result.get();
        }

        String password = getRandomPassword(12);

        ClubMember clubMember = ClubMember.builder()
                .email(email)
                .name(email)
                .password(new BCryptPasswordEncoder().encode(password))
                .fromSocial(true)
                .build();

        clubMember.addMemberRole(ClubMemberRole.USER);

        clubMemberRepository.save(clubMember);

        return clubMember;
    }

    private String getRandomPassword(int size) {
        return RandomStringUtils.randomAlphanumeric(size);
    }
}

OAuth2UserService는 쉽게 말해서 DB로부터 사용자 인증 정보를 가져오는 UserDetailsService의 OAuth 버전으로 DefaultOAuth2UserService를 상속 받아 구현하였습니다.

 

loadUser() 메서드는 OAuth2UserRequest라는 타입의 파라미터를 입력받아 OAuth2User라는 타입을 리턴합니다. OAuth2User은 UserDetail를 구현할 때 OAuth2User 인터페이스까지 구현하였으므로 ClubAuthMemberDTO를 그대로 사용합니다.

 

saveSocialMember() 메서드는 OAuth2User를 통해 얻은 email로 DB에서 조회하여 이미 등록된 회원이면 그대로 리턴하고 아직 회원이 아니면 임시 비밀번호를 랜덤으로 생성하여 DB에 저장한 후 리턴하는 메서드입니다.

 

getRandomPassword() 메서드는 메서드 명에서도 유추할 수 있듯이 임시 비밀번호를 생성하는 메서드 입니다.

AuthenticationSuccessHandler

@Log4j2
@RequiredArgsConstructor
public class ClubLoginSuccessHandler implements AuthenticationSuccessHandler {

    private final JWTUtil jwtUtil;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request,
                                        HttpServletResponse response,
                                        Authentication authentication) throws IOException, ServletException {
        log.info("----------------------------");
        log.info("onAuthenticationSuccess");

        ClubAuthMemberDTO authMember = (ClubAuthMemberDTO)authentication.getPrincipal();

        boolean fromSocial = authMember.isFromSocial();

        if (fromSocial) {
            String token = jwtUtil.createToken(authMember.getEmail());
            response.setContentType("text/plain");
            response.getOutputStream().write(token.getBytes());

            log.info(token);
        }

    }
}

formLogin()이나 oauth2Login()을 통해 로그인 시도 시 로그인에 성공했을 때 이후 과정을 처리할 메서드로 AuthenticationSuccesshandler 인터페이스를 구현했습니다. 

 

JWT 토큰 인증 방식을 사용했을때 oauth2Login()을 통해 소셜로그인 시 JWT 토큰을 발급해주기 위해 HttpServletResponse 객체에 담아 리턴해주었습니다.

JWTUtil

@RequiredArgsConstructor
@Component
public class JWTUtil {

    // 암호화할 때 필요한 비밀 키(secret key)
    private String secretKey = "myprojectsecret";
    
    // 토큰 유효시간 60분
    private long tokenValidTime = 60 * 60 * 1000L;

    private final ClubUserDetailsService userDetailsService;

    // secretKey 객체 초기화, Base64로 인코딩
    @PostConstruct
    protected void init() {
        secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes());
    }

    public String createToken(String email) {
        Claims claims = Jwts.claims().setSubject(email);
        Date now = new Date();
        return Jwts.builder()
                .setClaims(claims) 
                .setIssuedAt(now) 
                .setExpiration(new Date(now.getTime() + tokenValidTime)) 
                .signWith(SignatureAlgorithm.HS256, secretKey)  
                .compact();
    }

    public Authentication getAuthentication(String token) {
        UserDetails userDetails = userDetailsService.loadUserByUsername(this.getUserPk(token));
        return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
    }

    public String getUserPk(String token) {
        return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody().getSubject();
    }

    public String resolveToken(HttpServletRequest req) {
        String bearerToken = req.getHeader("Authorization");
        if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
            return bearerToken.substring(7);
        }
        return null;
    }

    public boolean validateToken(String jwtToken) {
        try {
            Jws<Claims> claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(jwtToken);
            return !claims.getBody().getExpiration().before(new Date());
        } catch (Exception e) {
            return false;
        }
    }
}

JWT Token과 관련된 메서드가 담겨있는데 JWTUtil 클래스 입니다.

  • createToken() : 유저의 PK 정보를 가지고 JWT Token을 생성하는 메서드입니다.
  • getAuthentication() : UserDetailsService 인터페이스를 구현한 커스텀 클래스에서 JWT Token에 저장된 유저의 PK 정보를 가지고 UserDetails 객체를 생성하고 생성된 객체로 UsernamePasswordAuthenticationToken 객체를 리턴해주는 메서드입니다.
  • getUserPk() : JWT 토큰에 저장된 유저의 PK정보를 가져오는 메서드입니다.
  • resolveToken() :  클라이언트 요청의 헤더에 담겨있는 이름이 Authorization인 키의 값을 가져오는 메서드입니다. 참고로 Authorization 헤더 메시지의 경우 토큰 앞에 인증 타입을 함께 보내는데 일반적인 경우에는 Basic을 사용하고, JWT를 이용할 때는 Bearer를 사용합니다. 때문에 인증 타입을 제외한 토큰 정보만 받아오기 위해 substring()을 사용하였습니다.
  • validateToken() : 토큰의 유효성과 만료일자를 확인하는 메서드입니다.

JwtAuthenticationFilter

@RequiredArgsConstructor
public class JwtAuthenticationFilter extends GenericFilterBean {

    private final JWTUtil jwtUtil;

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {

        String token = jwtUtil.resolveToken((HttpServletRequest) request);

        if (token != null && jwtUtil.validateToken(token)) {
            Authentication authentication = jwtUtil.getAuthentication(token);
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }
        
        chain.doFilter(request, response);
    }
}

SecurityConfig 파일에서 설정한 권한이 필요한 요청시마다 JWT Token의 유효성을 검사하는 커스텀 filter입니다. 토큰이 유효한 경우 JWTUtil 클래스에서 Authentication 객체를 받아오고 이를 SecurityContext에 저장합니다.

 

doFilter() 메서드에서 마지막의 chian.doFilter()는 현재 필터의 다음 단계로 넘어가는 역할을 수행합니다. SecurityConfig 파일에서 커스텀한 필터를 UsernamePasswordAuthenticationFIlter 앞에 오도록 설정했으므로 여기서는 UsernamePasswordAuthenticationFIlter가 실행됩니다.