[Spring - Security] OAuth2 클라이언트와 Security 기초 인증 / 인가 처리 (Feat - Kakao Login && Loacl Login) - 2

2024. 5. 19. 14:10Spring

 

코드 버전

 

Spring boot: 3.2.1

Java: JDK 17.0.9

Gradle - Groovy

 

https://zks145.tistory.com/107

 

[Spring - Security] OAuth2 클라이언트와 Security 기초 인증 / 인가 처리 (Feat - Kakao Login && Local Login) - 1

코드 버전 Spring boot: 3.2.1Java: JDK 17.0.9Gradle - Groovy 의존성//securityimplementation 'org.springframework.boot:spring-boot-starter-oauth2-client'implementation 'org.springframework.boot:spring-boot-starter-security' //jwtimplementation 'io.j

zks145.tistory.com

 

Entity - Users

 

저장할 유저의 정보 엔티티를 생성해준다. 여기서 name = 유저 실제 이름 username = OAuth2 유저 고유 ID + 가입 사이트 (kakao, naver, google...)
OAuth2로 회원가입을 할 경우 username을 이용해 재회원가입을 누르게 되면 자동으로 로그인처리를 하기 위함이다. (중복 가입도 예방할 수 있다)

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class Users {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "userId")
    private Long userId;

    @Column(length = 45)
    private String email;

    @Column(length = 100)
    private String password;

    private String name;

    private String username;

    @Enumerated(EnumType.STRING)
    private Role role;
    
}

 

Entity - Role

 

유저 권한 정보 Enum 형태로 만들어 줬다.

public enum Role {
    ADMIN, USER
}

 

Dto - UserDto

 

엔티티에 직접 데이터를 전달할 경우 순환 참조 문제와 엔티티 컬럼값이 노출되는 문제등이 발생하기 때문에 Dto를 이용해 값을 전달할 것이다. 현재 제가 좋아하는 구현 방식은 엔티티 별 Dto를 생성하고 해당 Dto에 static class로 기능 별 분리 구현을 해 호출해 사용하는 것을 선호합니다.

 

public class  UserDto{
    
    @AllArgsConstructor
    @NoArgsConstructor
    @Getter
    public static class SignUp {
        @NotBlank(message = "이메일을 입력해주세요.")
        @Pattern(regexp = "^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+.[A-Za-z]{2,6}$", message = "이메일 형식이 올바르지 않습니다.")
        private String email;

        @NotBlank(message = "비밀번호를 입력해주세요.")
        @Pattern(regexp = "^(?=.*[A-Za-z])(?=.*[0-9])(?=.*[$@!%*#?&]).{8,16}$",
                message = "비밀번호는 영문자, 숫자, 특수문자를 포함한 8~16자리여야 합니다.")
        private String password;

        @NotBlank(message = "비밀번호 확인을 입력해주세요.")
        @Pattern(regexp = "^(?=.*[A-Za-z])(?=.*[0-9])(?=.*[$@!%*#?&]).{8,16}$",
                message = "비밀번호는 영문자, 숫자, 특수문자를 포함한 8~16자리여야 합니다.")
        private String password_confirm;

        @Pattern(regexp = "^[ㄱ-ㅎ가-힣a-z0-9-_]{2,10}$", message = "닉네임은 특수문자를 제외한 2~10자리여야 합니다.")
        @NotBlank(message = "이름을 입력해주세요.")
        private String name;
    }

    @AllArgsConstructor
    @NoArgsConstructor
    @Getter
    public static class SignIn {
        @Email
        @NotBlank(message = "이메일을 입력해주세요.")
        private String email;
        @NotBlank(message = "비밀번호를 입력해주세요.")
        private String password;
    }
}

 

참조 링크

JwtUtil

 

이전 글에서 작성한 버전에서 사용할 코드이다. 버전별로 return에서 사용하는 코드가 조금씩 다르다.

jwt에서 카테고리 분류를 하는 이유는 Access Token과 Refresh Token을 분리 구현을해 자동 발급하는 코드도 작성할 예정이기 때문이다. 이후에 작성할 JwtFilter와 LoginFilter에서 Access Token이 expired(만료)된 경우 Refresh Token을 이용해 새로운 Access Token을 재발급할 것이다. 

@Component
@Slf4j
public class JwtUtil {

    private final SecretKey secretKey;

    public JwtUtil(@Value("${SECRET_KEY}") String secret) {
        secretKey = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), Jwts.SIG.HS256.key().build().getAlgorithm());
    }

    public String getCategory(String token) {
        return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("category", String.class);
    }
    public String getUserId(String token) {
        return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("id", String.class);
    }

    public String getName(String token) {
        return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("name", String.class);
    }

    public String getRole(String token) {
        return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("role", String.class);
    }

    public boolean isExpired(String token) {
        return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().getExpiration().before(new Date());
    }

    // jwt 토큰 생성
    public String createJwt(String category, String id, String name, String role, Long expireTime) {

        return Jwts.builder()
                .claim("category", category)
                .claim("id", id)
                .claim("name", name)
                .claim("role", role)
                .issuedAt(new Date(System.currentTimeMillis())) // 현재 시간
                .expiration(new Date(System.currentTimeMillis() + expireTime)) // 만료 시간
                .signWith(secretKey)
                .compact();
    }


}

 

JwtFilter

 

OAuth2 로그인을 이용해 토큰을 발급 받을 때 사용하는 Filter이다.

doFilterInternal에서 헤더를 통해 access 토큰 발급 여부를 확인하고 만약 공백의 값이라면 새로운 accessToken을 발급해 반환한다.

@Slf4j
public class JwtFilter extends OncePerRequestFilter {

    private final JwtUtil jwtUtil;

    public JwtFilter(JwtUtil jwtUtil) {

        this.jwtUtil = jwtUtil;
    }

    // 인증 전 거름망
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {

        // 헤더에서 access키에 담긴 토큰을 꺼냄
        String accessToken = request.getHeader("access");

        // 토큰이 없다면 다음 필터로 넘김
        if (accessToken == null) {

            filterChain.doFilter(request , response);

            log.info("token null");
            return;
        }

        log.info("header = {}", accessToken);

        // 토큰 만료 여부 확인, 만료시 다음 필터로 넘기지 않음
        try {
            jwtUtil.isExpired(accessToken);
        } catch (ExpiredJwtException e) {

            //response body
            PrintWriter writer = response.getWriter();
            writer.print("access token expired");

            //response status code
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            return;
        }

        // 토큰이 access인지 확인 (발급시 페이로드에 명시)
        String category = jwtUtil.getCategory(accessToken);

        if (!category.equals("access")) {

            //response body
            PrintWriter writer = response.getWriter();
            writer.print("invalid access token");

            //response status code
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            return;
        }

        String id = jwtUtil.getUserId(accessToken);
        String name = jwtUtil.getName(accessToken);
        String role = jwtUtil.getRole(accessToken);
        log.info("id = {} name = {} role = {}", id, name, role);

        Users oAuth2UserDto = Users.builder()
                .userId(Long.parseLong(id))
                .name(name)
                .role(Role.valueOf(role))
                .build();

        //UserDetails에 회원 정보 객체 담기
        PrincipalDetails customOAuth2User = new PrincipalDetails(oAuth2UserDto);

        // 스프링 시큐리티 인증 auth 토큰 생성
        Authentication authentication =
                new UsernamePasswordAuthenticationToken(customOAuth2User, null, customOAuth2User.getAuthorities());

        //세션에 사용자 등록
        SecurityContextHolder.getContext().setAuthentication(authentication);

        filterChain.doFilter(request, response);

    }

}

 

 

LoginFilter

 

 

@Slf4j
public class LoginFilter extends UsernamePasswordAuthenticationFilter {

    private final AuthenticationManager authenticationManager;
    private final JwtUtil jwtUtil;
    private final RefreshRepository refreshRepository;

    public LoginFilter(AuthenticationManager authenticationManager, JwtUtil jwtUtil, RefreshRepository refreshRepository) {
        this.authenticationManager = authenticationManager;
        this.jwtUtil = jwtUtil;
        this.refreshRepository = refreshRepository;
    }

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {

        UserDto.SignIn loginDto = new UserDto.SignIn();

        try {
            ObjectMapper objectMapper = new ObjectMapper();
            ServletInputStream inputStream = request.getInputStream();
            String messageBody = StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8);
            loginDto = objectMapper.readValue(messageBody, UserDto.SignIn.class);

        } catch (IOException e) {
            throw new RuntimeException(e);
        }

        log.info("LoginFilter loginData = {}, {}", loginDto.getEmail(), loginDto.getPassword());

        String username = loginDto.getEmail();
        String password = loginDto.getPassword();

        UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(username, password, null);

        return authenticationManager.authenticate(authToken);
    }

    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain, Authentication authentication) {

        log.info("인증 성공 : successfulAuthentication");
        // 유저 정보
        PrincipalDetails customUserDetails = (PrincipalDetails) authentication.getPrincipal();

        String id = customUserDetails.getId();
        String name = customUserDetails.getUsername();

        Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
        Iterator<? extends GrantedAuthority> iterator = authorities.iterator();
        GrantedAuthority auth = iterator.next();
        String role = auth.getAuthority();

        // 토큰 생성
        String access = jwtUtil.createJwt("access", id, name, role, 60 * 1000 * 10L);
        String refresh = jwtUtil.createJwt("refresh", id, name, role, 60 * 1000 * 60 * 24L);

        log.info("LoginFilter access = {}", access);
        log.info("LoginFilter refresh = {}", refresh);

        // 리프레시 토큰 DB 저장
        addRefreshEntity(name, refresh, 60 * 1000 * 60 * 24L);

        response.setHeader("access", access);
        response.addCookie(createCookie("refresh", refresh));
        response.setStatus(HttpStatus.OK.value());
    }

    @Override
    protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException {

        log.info("인증 실패 : unsuccessfulAuthentication");
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        response.setContentType("application/json;charset=UTF-8");

        JSONObject jsonObject = new JSONObject();

        if (failed instanceof BadCredentialsException) {
            log.info("BadCredentialsException : 잘못된 비빌번호 입력");
            jsonObject.put("message", "이메일 또는 비밀번호를 확인해주세요.");
        } else {
            log.info("Authentication failed : 유효하지 않은 이메일");
            jsonObject.put("message", "이메일 또는 비밀번호를 확인해주세요.");
        }

        response.getWriter().print(jsonObject);

    }

    private void addRefreshEntity(String name, String refreshToken, Long expiredMs) {
        Date date = new Date(System.currentTimeMillis() + expiredMs);

        RefreshEntity refreshEntity = RefreshEntity.builder()
                .name(name)
                .refreshToken(refreshToken)
                .expiration(date.toString())
                .build();

        refreshRepository.save(refreshEntity);

    }

    private Cookie createCookie(String key, String value) {

        Cookie cookie = new Cookie(key, value);
        cookie.setMaxAge(24 * 60 * 60);
//        cookie.setPath("/");
        cookie.setHttpOnly(true);

        return cookie;
    }

}

 

1. attemptAuthentication: DB에 이메일과 pw를 입력해 사용자 정보를 확인하고 인증이 완료된 경우 인증된 생성자로 Authentication 객체를 생성한다. UsernamePasswordAuthenticationToken 클래스 내부를 살펴보면 AbstractAuthenticationToken을 상속받고 해당 클래스는 또 Authentication을 상속 받는다.

 

1차적으로 UsernamePasswordAuthenticationToken 클래스에서 유저 정보를 인증 받으면 setAuthenticated 값이 true로 반환될 것이고 Authentication 객체는 SecurityContextHolder.getContext()에 저장될 것이다. 

 

2. successfulAuthentication: Authentication 객체가 인증을 성공하면 실행되는 코드로 각각의 성공 시나리오에 맞춰서 코드를 작성하면 된다. 

여기서는 JWT 토큰을 이용해 인증/인가 처리를 하기 위해서 Access, Refresh Token을 각각 발급 받았다.

 

- addRefreshEntity: AccessToken이 만료된 경우에 해당 토큰 값을 갱신해주기 위해 Refresh을 사용하게 되는데 확인을 위해 저장을 해둔다. 기본적으로 AccesToken은 발급 후 관리를 서버에서 하지 않기 때문에 인증된 사용자라는 확인을 위해 저장한다.

 

3. unsuccessfulAuthentication: Authentication  객체 인증이 실패한 경우는 이메일 혹은 비빌번호 입력 중 실패 원인이 있으므로 찾아내 상태를 확인한다.

 

CustomOauth2UserService

 

Auth 2.0 로그인을 하게되면 요청하는 서비스의 종류를 판단하고 요청하는 역할을 하는 코드이다.

현재는 카카오 로그인만 사용하였기 때문에 if문을 이용해 분류할 필요는 없지만 naver, google 같은 다른 서비스가 추가되면 분류 코드를 하단에 추가해주면 되기에 미리 작성하였다.

 

여기서 나름 중요하다고 생각하는 부분은 OAuth2 를 이용해 사용자 정보를 가져왔을 때 결과를 처리하는 방식이다. 기본적으로 naver를 제외한 다른 서비스에서는 사용자가 요청한 email이 변경되지 않은 실제 email이지만 naver 서비스의 경우 가상의 email 정보를 주기 때문에 username에 고유 번호와 서비스 정보를 저장해 별도 분류가 가능하게 만들었다.

 

또한 기존 로컬 회원 가입을 통해 OAuth2에 사용한 이메일이 있는지 확인하고 가입을 승인한다.

@Service
@Slf4j
@RequiredArgsConstructor
public class CustomOAuth2UserService extends DefaultOAuth2UserService {

    private final UserRepository userRepository;

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

        OAuth2User oAuth2User = super.loadUser(userRequest);
        log.info("oAuth2User Data = {}" ,oAuth2User);

        String registrationId = userRequest.getClientRegistration().getRegistrationId();

        OAuth2Response oAuth2Response = null;

        if (registrationId.equals("kakao")) {
            oAuth2Response = new KakaoMemberInfoResponse(oAuth2User.getAttributes());
        }
        else {
            return null;
        }

        String username = oAuth2Response.getProvider()+" "+oAuth2Response.getProviderId();

        String email = oAuth2Response.getEmail();

        Users findUsernameAndEmail = userRepository.findByUsernameOrEmail(username, email);

        log.info("findUsernameAndEmail = {}", findUsernameAndEmail);

        if (findUsernameAndEmail == null) {

            findUsernameAndEmail = Users.builder()
                    .username(username)
                    .userEmail(email)
                    .name(oAuth2Response.getName())
                    .role(Role.USER)
                    .build();

            userRepository.save(findUsernameAndEmail);

            return new PrincipalDetails(findUsernameAndEmail, oAuth2User.getAttributes());

        }
        else if (findUsernameAndEmail.getEmail() != null && findUsernameAndEmail.getUsername() == null) {
            OAuth2Error oAuth2Error = new OAuth2Error("error");
            throw new OAuth2AuthenticationException(oAuth2Error, oAuth2Error.toString());
        }
        else {
            findUsernameAndEmail.updateOAuth2(oAuth2Response.getName(), email);

            userRepository.save(findUsernameAndEmail);

            return new PrincipalDetails(findUsernameAndEmail, oAuth2User.getAttributes());
        }

    }
}

 

PrincipalDetails

 

처음 계획은 로컬 로그인 없이 OAuth2를 이용해 회원가입을 코드를 작성하려고 하였으나 추가된 로컬 로그인으로 인해 기존 클래스를 상송해 하나로 통합해 처리하는 코드를 작성하였다. SpringSecurity SecurityContextHolder에 저장하는 방식 자체는 비슷해 통합하게 되었다. 

 

public class PrincipalDetails implements UserDetails, OAuth2User {

    private Users user;
    private Map<String, Object> attributes;

    public PrincipalDetails(Users user) {
        this.user = user;
    }

    public PrincipalDetails(Users user, Map<String, Object> attributes) {
        this.user = user;
        this.attributes = attributes;
    }

    /** OAuth2 **/
    @Override
    public Map<String, Object> getAttributes() {
        return attributes;
    }

    @Override
    public String getName() {
        return user.getName();
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {

        Collection<GrantedAuthority> collection = new ArrayList<>();

        collection.add(new GrantedAuthority() {
            @Override
            public String getAuthority() {
                return user.getRole().toString();
            }
        });

        return collection;
    }

    @Override
    public String getPassword() {
        return user.getPassword();
    }

    @Override
    public String getUsername() {
        return user.getName();
    }

    public String getId() {
        return user.getUserId().toString();
    }

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

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

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

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

}

 

만약 각각의 코드 구현을 하고 싶다면 UserDetails, OAuth2User 클래스를 분리 구현하면 된다.

 

CustomSuccessHandler

 

OAuth2 로그인에 성공하면 실행되는 클래스로 토큰 생성과 기존 로컬 로그인 성공과 동일하게 Refresh Token을 저장하는 코드까지 작성하였다.

@Component
@Slf4j
public class CustomSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {

    private final JwtUtil jwtUtil;
    private final RefreshRepository refreshRepository;

    public CustomSuccessHandler(JwtUtil jwtUtil, RefreshRepository refreshRepository) {
        this.jwtUtil = jwtUtil;
        this.refreshRepository = refreshRepository;
    }

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

        PrincipalDetails customUserDetails = (PrincipalDetails) authentication.getPrincipal();

        String id = customUserDetails.getId();
        String name = customUserDetails.getName();

        Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
        Iterator<? extends GrantedAuthority> iterator = authorities.iterator();
        GrantedAuthority auth = iterator.next();
        String role = auth.getAuthority();

        // 토큰 생성
        String refresh = jwtUtil.createJwt("refresh", id, name, role, 60 * 1000 * 60 * 24L);

        // 리프레시 토큰 DB 저장
        log.info("onAuthenticationSuccess addRefreshEntity");
        addRefreshEntity(name, refresh, 60 * 1000 * 60 * 24L);

        log.info("onAuthenticationSuccess refresh = {}", refresh);
        response.addCookie(createCookie("refresh", refresh, true));

        response.sendRedirect("http://localhost:3000/home");
    }

    private void addRefreshEntity(String name, String refreshToken, Long expiredMs) {
        Date date = new Date(System.currentTimeMillis() + expiredMs);

        RefreshEntity refreshEntity = RefreshEntity.builder()
                .name(name)
                .refreshToken(refreshToken)
                .expiration(date.toString())
                .build();

        refreshRepository.save(refreshEntity);

    }

    private Cookie createCookie(String key, String value, boolean httpOnly) {

        Cookie cookie = new Cookie(key, value);
        cookie.setMaxAge(1000 * 60 * 60);
        cookie.setPath("/");
        cookie.setHttpOnly(httpOnly);

        return cookie;
    }
}

 

CustomFailHandler

 

처음 구현할 때 상속 받아야하는 인터페이스를 찾지 못해서 고생했다. 여기서는 로그인 실패시 남은게 재로그인이기 때문에 리다이렉트 구현만 해주었다.

@Component
@Slf4j
public class CustomFailHandler implements AuthenticationFailureHandler {

    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException {

        // 인증 실패 시 클라이언트 측의 URL로 리다이렉트
        log.info("onAuthenticationFailure : 실패");
        String redirectUrl = "http://localhost:3000/";
        response.sendRedirect(redirectUrl);

    }
}

 

OAuth2Response

 

이름을 Response라고 지었지만 SecurityContextHolder에 저장할 값과 저장된 값을 호출하는데 사용한다.  

public interface OAuth2Response {

    String getProvider();
    String getProviderId();
    String getEmail();
    String getName();
}