[빙터뷰] 사용자 인증기능 구현

impala·2023년 6월 27일
0
post-thumbnail

사용자의 정보를 얻어 서비스를 이용할 수 있도록 등록하고, 로그인된 사용자만 서비스에 접근할 수 있도록 인증/인가 시스템을 구축하였다. 스프링부트를 사용하여 서버를 개발중이기 때문에 스프링 시큐리티를 사용했고, OAuth와 JWT를 이용하여 인증과 인가를 각각 구현하였다. 각각의 기술을 선택한 이유는 다음과 같다.

  • OAuth 2.0 : 짧은 시간안에 로그인 서비스를 구현하고 사용자의 개인정보를 관리하기 위해서는 직접 인증시스템을 구축하는 것 보다 OAuth를 통해 구글에 인증과 관련된 작업을 위임하는 것이 효율적이라고 판단하여 도입했다.
  • JWT : OAuth에서 발급한 토큰에 포한된 정보와 별도로 추가정보를 토큰에 넣어 전달하기 위해 JWT를 사용했다.

Spring Security

Spring Security란 스프링에서 제공하는 별도의 프레임워크로, 인증 및 인가에 대한 처리를 담당한다. 스프링 시큐리티는 여러 필터를 통해 인증/인가에 관련된 처리를 수행하여 http요청이 Dispatcher Servlet에 도달하기 전에 요청을 accept하거나 reject할 수 있다.

빙터뷰에서는 로그인 요청을 OAuth로 위임하고 JWT를 통한 인가 처리를 위해 스프링 시큐리티를 사용하였다.

스프링 시큐리티에 대한 자세한 내용은 링크에 정리하였다.

Authentication

빙터뷰의 인증 프로세스는 다음과 같다.

  1. 클라이언트에서 /login으로 http요청을 보내면 Spring Security에서 로그인 페이지를 보여준다
  2. 로그인 버튼을 누르면 구글 소셜로그인 페이지로 연결된다.
  3. 인증에 성공하면 서버에서 회원가입 및 로그인을 처리하고 토큰을 발급하여 쿼리 파라미터를 통해 전달한다.

OAuth 2.0

OAuth(Open Authorization)이란 사용자 인증을 위한 개방형 프로토콜이다. 이는 서버에서 제공하는 자원에 대한 접근권한을 Third-Party(구글, 네이버, 카카오 등)에게 위임하여 서버에서 직접 사용자의 개인정보를 관리하지 않고 사용자를 인증할 수 있도록 하는 프로토콜이다.

OAuth에 대한 자세한 내용은 링크에 정리하였다.

구현

SecurityConfig

@Configuration
@RequiredArgsConstructor
@EnableWebSecurity  // SpringSecurity 설정 활성화
public class SecurityConfig {

    private final OAuth2MemberService oAuth2MemberService;
    private final OAuth2SuccessHandler oAuth2SuccessHandler;

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

        http
            .cors().and()
            .csrf().disable()
            .headers().frameOptions().disable()
            .and()
                .authorizeHttpRequests()
                    .requestMatchers("/","/resources/**","/login", "/auth", "/token", "/v3/api-docs/**","/swagger-ui/**").permitAll()
                    .anyRequest().authenticated()
            .and()
                .logout()
                    .logoutSuccessUrl("/logout-success")
            .and()
                .oauth2Login()
                    .successHandler(oAuth2SuccessHandler)
                    .userInfoEndpoint()
                        .userService(oAuth2MemberService);

        return http.build();
    }
}

Spring Security에서 OAuth2.0인증을 사용하기 위해 .oauth2Login() 옵션을 활성화하고 oAuth2MemberServiceoAuth2SuccessHandler 를 등록한다.

  • oAuth2MemberService : 로그인 요청이 처리되는 엔드포인트. 요청을 받으면 OAuth인증 수행 후 인증 정보를 생성함
  • oAuth2SuccessHandler : 로그인 성공시 DB에서 사용자 정보를 조회하거나 새로운 사용자를 등록하고 토큰을 발급하여 로그인 요청에 응답함

OAuthAttributes

@Getter
public class OAuthAttributes {

    private Map<String, Object> attributes;
    private String nameAttributeKey;
    private String name;
    private String email;

    @Builder
    public OAuthAttributes(Map<String, Object> attributes, String nameAttributeKey, String name, String email) {
        this.attributes = attributes;
        this.nameAttributeKey = nameAttributeKey;
        this.name = name;
        this.email = email;
    }

    public static OAuthAttributes of(String registrationId, String userNameAttributeName, Map<String, Object> attributes) {

        return OAuthAttributes.builder()
                .name(attributes.get("name").toString())
                .email(attributes.get("email").toString())
                .attributes(attributes)
                .nameAttributeKey(userNameAttributeName)
                .build();
    }
}

사용자의 인증정보를 담은 객체로 구글 소셜로그인을 통해 얻은 atrribute를 담고있다.

OAuth2MemberService

@Slf4j
@RequiredArgsConstructor
@Service
public class OAuth2MemberService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {

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

        DefaultOAuth2UserService delegate = new DefaultOAuth2UserService();
        OAuth2User oAuth2User = delegate.loadUser(userRequest);

        String registrationId = userRequest.getClientRegistration().getRegistrationId();
        String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName();

        OAuthAttributes attributes = OAuthAttributes.of(registrationId, userNameAttributeName, oAuth2User.getAttributes());

        return new DefaultOAuth2User(
                null,
                attributes.getAttributes(),
                attributes.getNameAttributeKey()
        );
    }
}

OAuth2UserService를 상속받아 로그인요청을 처리하는 클래스로, delogate.loadUser(userRequest) 를 통해 OAuth인증을 수행하고 인증된 사용자 정보를 포함한 DefaultOAuth2User객체를 생성한다.

OAuth2SuccessHandler

@Slf4j
@RequiredArgsConstructor
@Component
public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler {

    private final MemberRepository memberRepository;
    private final JwtTokenProvider tokenProvider;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException {

        log.info("OAuth2 authenticated");

        OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal();
        Map<String, Object> attributes = oAuth2User.getAttributes();
        String email = (String) attributes.get("email");
        String name = (String) attributes.get("name");
        log.info("email {} , name {}", email, name);
        Member member = memberRepository.findByEmail(email)
                .orElse(Member.builder()
                        .email(email)
                        .name(name)
                        .profileImageUrl("https://vingterview.s3.ap-northeast-2.amazonaws.com/image/1b9ec992-d85f-4758-bbfc-69b0c68ccc47.png")
                        .build());
        log.info("save member");
        memberRepository.save(member);

        Token token = tokenProvider.generateToken(member.getId(), email);

        String targetUrl = UriComponentsBuilder.fromUriString("/token")
                .queryParam("access_token", token.getAccessToken())
                .queryParam("refresh_token", token.getRefreshToken())
                .build().toUriString();
        getRedirectStrategy().sendRedirect(request, response, targetUrl);
    }
}

로그인 성공시 처리되는 로직으로, 인증객체에서 사용자정보를 꺼내 DB에서 조회하거나 DB에 사용자를 등록한다. 이후 이메일과 사용자 번호를 사용하여 JWT토큰을 만들고 쿼리 파라미터에 토큰정보를 추가하여 리다이렉트한다.

Authorization

사용자가 빙터뷰의 각종 서비스에 접근하기 위해서 위에서 발급받은 JWT를 인가 프로세스를 진행한다.

  1. 클라이언트에서 api요청시 http헤더에 다음과 같은 형식으로 토큰을 추가하여 요청한다.

    Authorization: Bearer ${access_token}

  2. 필터에서 요청을 받아 토큰의 유효성을 검사하고, 인증된 사용자를 Security Context에 등록한다.

JWT

JWT(Json Web Token)는 웹 표준을 따르면서 JSON객체를 사용하여 인증 정보를 전달하는 토큰으로, URL-safe하기 때문에 쿼리 파라미터를 통해 토큰을 주고받을 수 있다는 장점이 있다.

JWT에 대한 자세한 내용은 링크에 정리하였다.

구현

SecurityConfig

public class SecurityConfig {

    ...
    private final JwtTokenProvider tokenProvider;

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

        http
        ...
            .and()
            .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
        ...

        http.addFilterBefore(new JwtAuthFilter(tokenProvider), UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }
}

Spring Security에서 토큰방식의 인가 프로세스를 사용하기 위해 세션정책을 SessionCreationPolicy.STATELESS 로 바꾸고, 토큰을 검증하는 JwtAuthFilter를 UsernamePasswordAuthenticationFilter앞에 추가한다. 이렇게 하면 JwtAuthFilter에서 토큰의 유효성이 검증되면 인가된 사용자를 Security Context Holder에 등록하기 때문에, UsernamePasswordAuthenticationFilter를 통과할 수 있다.

JwtAuthFilter

@Slf4j
@RequiredArgsConstructor
public class JwtAuthFilter extends GenericFilterBean {

    private final JwtTokenProvider tokenProvider;

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        String token = getToken((HttpServletRequest) request);

        if (token != null && tokenProvider.verifyToken(token)) {
            log.info("JWT Token authorized");
            Authentication authentication = tokenProvider.getAuthentication(token);
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }

        chain.doFilter(request, response);
    }

    private String getToken(HttpServletRequest request) {
        String header = request.getHeader("Authorization");
        if (header != null && header.startsWith("Bearer ")) {
            return header.substring(7);
        }
        return null;
    }
}

JwtAuthFilter는 요청헤더에서 토큰을 얻어 검증하고, 검증에 성공하면 인증객체를 SecurityContextHolder에 등록하여 http요청에 서비스 접근 권한을 부여한다.

JwtTokenProvider

@Component
@RequiredArgsConstructor
public class JwtTokenProvider {

    @Value("${jwt.secret}")
    private String jwtSecret;

    //access token 지속시간 : 3개월(임시)
    private Long accessTokenValidTime = 3 * 30 * 24 * 60 * 60 * 1000L;

    //refresh token 지속시간 : 3개월
    private Long refreshTokenValidTime = 3 * 30 * 24 * 60 * 60 * 1000L;

    //TODO 토큰 재발급(DB에 저장된 refresh Token으로 사용자 조회 후 토큰 재발급)
    public Token refresh(String refreshToken) {
//        if (refreshToken != null) {
//            Long memberId = null;
//            String email = null;
//            return generateToken(memberId, email);
//        }
        return null;
    }

    public Token generateToken(Long memberId, String userEmail) {
        return new Token(
                generateAccessToken(memberId, userEmail),
                generateRefreshToken()
        );
    }

    public Authentication getAuthentication(String token) {
        Claims claims = getClaims(token);
        String email = claims.getSubject();
        Long memberId = Long.valueOf(claims.get("memberId", String.class));
        JwtUserDetails principal = new JwtUserDetails(memberId, email);
        return new UsernamePasswordAuthenticationToken(principal, "", null);
    }

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

    private Claims getClaims(String token) {
        return Jwts.parser().setSigningKey(jwtSecret).parseClaimsJws(token).getBody();
    }

    private String generateAccessToken(Long memberId, String userEmail) {
        Claims claims = Jwts.claims().setSubject(userEmail);
        Date now = new Date();

        return Jwts.builder()
                .setClaims(claims)
                .claim("memberId", String.valueOf(memberId))
                .setIssuedAt(now)
                .setExpiration(new Date(now.getTime() + accessTokenValidTime))
                .signWith(SignatureAlgorithm.HS256, jwtSecret)
                .compact();
    }

    private String generateRefreshToken() {
        Date now = new Date();

        return Jwts.builder()
                .setIssuedAt(now)
                .setExpiration(new Date(now.getTime() + refreshTokenValidTime))
                .signWith(SignatureAlgorithm.HS256, jwtSecret)
                .compact();
    }
}

JwtTokenProvider는 JWT를 발급하고 검증하는 역할을 담당하는 유틸리티 클래스로, DB에 등록된 사용자 번호와 이메일을 페이로드에 담아 JWT를 생성한다. 토큰의 유효성 검증에 성공하면 페이로드에서 이메일과 사용자 번호를 꺼내 JwtUserDetail에 담아 인증객체를 생성한다.

JwtUserDetail

@Getter
public class JwtUserDetails implements Serializable, UserDetails {

    private Long id;
    private String email;

    public JwtUserDetails(Long id, String email) {
        this.id = id;
        this.email = email;
    }

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

    @Override
    public String getPassword() {
        return null;
    }

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

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

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

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

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

JwtUserDetails는 UserDetails를 상속받은 클래스로, 인증된 사용자의 정보를 담고있다.

LoginMemberIdArgumentResolver

@Slf4j
@RequiredArgsConstructor
@Component
public class LoginMemberIdArgumentResolver implements HandlerMethodArgumentResolver {

    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        boolean isLoginMemberIdAnnotation = parameter.getParameterAnnotation(LoginMemberId.class) != null;
        boolean isLongClass = Long.class.equals(parameter.getParameterType());

        return isLoginMemberIdAnnotation && isLongClass;
    }

    @Override
    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
        JwtUserDetails principal = (JwtUserDetails) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
        return principal.getId();
    }
}

기존 코드가 컨트롤러에서 memberId를 받아 서비스를 호출하기 때문에 형식을 맞추기 위해 @LoginMemberId라는 어노테이션을 추가했다. @LoginMemberId 어노테이션이 달린 매개변수는 SecurityContextHolder에서 인증된 사용자 정보를 꺼내 memberId를 반환한다.

@RequiredArgsConstructor
@Configuration
public class WebConfig implements WebMvcConfigurer {
    private final LoginMemberIdArgumentResolver loginMemberIdArgumentResolver;

    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
        resolvers.add(loginMemberIdArgumentResolver);
    }
}

어노테이션을 사용하기 위해 WebMvcConfigurer를 상속받은 WebConfig클래스에 LoginMemberIdArgumentResolver를 등록했다.

문제점

  • 토큰 발급시 데이터베이스에 저장된 회원의 PK값과 이메일을 평문 그대로 페이로드에 넣어 로그인 토큰을 생성하였는데, JWT는 탈취가 가능하기 때문에 이를 조작하여 memberId를 조작하면 다른 사용자의 정보에 접근할 수 있다. 이를 방지하기 위해 예측가능한 PK값이 아닌 임의의 UUID값을 식별자로 사용하거나 memberId와 email을 암호화해서 토큰을 생성해야 할 것 같다.

0개의 댓글