[Spring] 스프링 시큐리티 적용하기

유승욱·2024년 2월 19일
0

현재 진행중인 프로젝트에 스프링 시큐리티와 소셜 로그인 기능을 추가하려한다.

스프링 시큐리티 기본 설정

스프링 시큐리티를 적용하려면 추가적인 라이브러리가 필요하므로 개발에 앞서 이를 적용하도록 하자. 프로젝트 내 build.gradle 파일의 dependencies에 스프링 시큐리티 관련 라이브러리를 추가해준다.

implementation 'org.springframework.boot:spring-boot-starter-security'

스프링 시큐리티 관련 설정 추가

스프링 시큐리티의 경우 단순히 application.properties를 이용하는 설정보다 코드를 이용해서 설정을 조정하는 경우가 더 많기 때문에 별도의 클래스를 이용해서 설정을 조정한다.


우선 프로젝트를 실행해보자.
security 설정이 완료된 후에 프로젝트를 실행하면 다음과 같이 알 수 없는 password가 생성되어 출력되는 것을 확인할 수 있다.(값은 매번 다르게 생성되지만 기본적으로 사용되는 사용자의 아이디는 'user'이다.)


실행된 프로젝트를 보면 기존에 아무 문제 없이 접근할 수 있는 url에 로그인 처리가 필요한 것을 확인할 수 있다. 이처럼 스프링 시큐리티는 별도의 설정이 없을 땐 모드 자원에 필요한 권한이나 로그인 여부 등을 확인한다.
처리 과정을 살펴보면 '/board/list'를 호출했지만 '/login' 경로로 리다이렉트 되는 것을 볼 수 있다.(이때 'user'라는 아이디와 생성된 패스워드로 로그인할 수 있다.)
로그인하지 않아도 볼 수 있도록 설정하고 싶다면 개발자가 직접 설정하는 코드가 반드시 있어야만 한다. CustomSecurityConfig에 다음 코드와 같이 SecurityFilterChain이라는 객체를 반환하는 메소드를 작성해준다.

filterChain() 메소드가 동작하면 이전과 달리 '/board/list'에 바로 접근할 수 있다.

앞선 결과로 짐작할 수 있는 것은 CustomSecurityConfig의 filterChain()메소드 설정으로 모든 사용자가 모든 경로에 접근할 수 있게 된다. filterChain()의 내부 코드를 이용해 최소한의 설정으로 필요한 자원의 접근을 제어할 수 있는 것이다.

로그 레벨 조정

스프링 시큐리티의 동작은 웹에서 사용하는 필터를 통해서 동작하고 상당히 많은 수의 필터들이 단계별로 동작하게 된다. 따라서 문제가 발생하면 어떤 필터에서 문제가 생겼는지 알 수 있도록 application.properties 파일의 로그 레벨에 다음과 같은 내용을 추가한다.

logging.level.org.springframework.security=trace

변경된 설정을 반영하고 '/board/list'를 호출해보면 상당히 많은 로그가 출력되는 것을 볼 수 있다.

정적 자원의 처리

앞선 로그들을 보면 단순한 css 파일이나 js 파일 등에도 필터가 적용되고 있는 것을 확인할 수 있다. 프로젝트에서는 완전히 정적으로 동작하는 파일들에는 굳이 시큐리티를 적용할 필요가 없으므로 CustomSecurityConfig에 WebSecurityCustomizer() 메소드 설정을 추가한다.

앞의 코드와 같이 설정하면 정적 자원들은 스프링 시큐리티 적용에서 제외시킬 수 있다.

인증과 인가/권한

스프링 시큐리티 전체를 관통하는 가장 중요한 개념은 인증(Authentication)과 인가(Authorization)라는 개념입니다.

• 인증(Authentication): '스스로를 증명하다'라는 뜻이며 흔히 말하는 로그인 개념입니다. 인증을 위해서 사용자는 자신이 알고 있는 자신의 정보를 제공하는데 아이디와 패스워드가 이에 속합니다.
• 인가(Authorization): '허가나 권한'이라는 개념과 같습니다. 인증이 된 사용자라고 해도 이에 접근할 수 있는 권한이 있는지를 확인하는 과정을 의미합니다.

웹 애플리케이션에서 스프링 시큐리티를 적용하면 로그인을 통해서 '인증'을 수행하고 컨트롤러의 경로에 시큐리티 설정으로 특정한 권한이 있는 사용자들만 접근할 수 있도록 설정하게 된다.

인증과 username

스프링 시큐리티에서 로그인에 해당하는 인증 처리는 다음과 같은 단계를 거쳐 동작한다.

■ 사용자의 아이디(username)만으로 사용자의 정보를 로딩
■ 로딩된 사용자의 정보를 이용해서 패스워드를 검증

스프링 시큐리티의 동작 방식은 웹에서 로그인 처리로 아이디와 패스워드를 한 번에 조회하는 방식과 달리 아이디(username)만을 이용해서 사용자 정보를 로딩하고 나중에 패스워드를 검증하는 방식이다.
인증 처리는 '인증 제공자(Authehtication)'라는 존재를 이용해서 처리되는데 인증 제공자와 그 이하의 흐름은 일반적으로 커스터마이징해야하는 경우가 거의 없으므로 실제 인증 처리를 담당하는 객체만을 커스터마이징하는 경우가 대부분이다.

인증 처리를 위한 UserDetailService

스프링 시큐리티에서 가장 중요한 객체는 실제로 인증을 처리하는 UserDetailService라는 인터페이스의 구현체이다. UserDetailService 인터페이스는 loadUserByUsername()이라는 단 하나의 메소드를 가지는데, 이것이 실제 인증을 처리할 때 호출되는 부분이다.
실제 개발 작업은 UserDetailService 인터페이스를 구현해서 username이라고 부르는 사용자의 아이디 인증을 코드로 구현하는 것이다.
CustomSecurityConfig에 로그인 화면에서 로그인을 진행한다는 설정을 다음과 같이 추가한다.

사용자가 인증되지 않은 상태에서 보호된 리소스에 접근하려고 하면, 스프링 시큐리티는 사용자를 로그인 페이지로 리다이렉트합니다.(지금은 경로를 설정해주지 않아서 기본 로그인 페이지인 '/login'으로 이동한다.)
프로젝트에 CustomUserDetailService 클래스를 추가한다.


프로젝트를 실행하고 '/login' 경로를 호출해서 아무 내용으로 로그인을 처리하면 아직 정상적으로 로그인 처리가 되는 것은 아니지면 추가한 CustomUserDetailService의 loadUserByUsername()가 실행되는 것을 확인할 수 있다.

UserDetails라는 반환 타입

loadUserByUsername()의 반환 타입은 UserDetails라는 인터페이스 타입으로 지정되어 있다. UserDetails는 사용자 인증(Authentication)과 관련된 정보들을 저장하는 역할을 한다.
스프링 시큐리티는 내부적으로 UserDetails 타입의 객체를 이용해서 패스워드를 검사하고, 사용자 권한을 확인하는 방식으로 동작한다. UserDetails의 여러 메소드들 중에서 getAuthorities() 메소드는 사용자가 가진 모든 인가(Authority)정보를 반환한다.
여기까지의 내용을 정리해보면 개발 단계에서 UserDetails라는 인터페이스 타입에 맞는 객체가 필요하고 이를 CustomUserDetailsService에서 반환하는 일이 필요하다.
스프링 시큐리티의 API에는 UserDetails 인터페이스를 구현한 User라는 클래스를 제공하므로 이를 임시로 만들어서 간단한 로그인 처리를 해보도록 하자.

PasswordEncoder

다시 실행해서 로그인해보면 passwordEncoder가 없어서 문제가 발생한다.

스프링 시큐리티는 기본적으로 PasswordEncoder라는 존재를 필요로 한다. PasswordEncoder 역시 인터페이스로 제공하는데 이를 구현하거나 스프링 시큐리티 API에서 제공하는 클래스를 지정할 수 있다.
여러 PasswordEncoder 타입의 클래스 중에서 가장 무난한 것은 BCryptPasswordEncoder라는 클래스이다. BCryptPasswordEncoder는 해시 알고리즘으로 암호화 처리되는데, 같은 문자열이라고 해도 매번 해시 처리된 결과가 다르므로 패스워드 암호화에 많이 사용된다.
CustomUserDetailService가 정상적으로 동작하려면 config 패키지의 CustomSecurityConfig에 PasswordEncoder를 @Bean으로 지정하고 CustomUserDetailService에 PasswordEncoder를 주입해야만 한다.

CustomUserDetailService에서 테스트 용으로 BCryptPasswordEncoder를 생성해 임시로 동작하도록 설계해보자.

프로젝트를 실행하고 로그인을 하면 제대로 로그인되는 것을 확인할 수 있다.

참고

PasswordEncoder는 스프링 시큐리티에서 제공하는 인터페이스로, 사용자의 비밀번호를 안전하게 저장하기 위해 사용됩니다.

일반적으로, 사용자의 비밀번호는 텍스트 그대로 데이터베이스에 저장하지 않습니다. 만약 데이터베이스가 해킹당하거나 정보가 유출되면, 사용자의 비밀번호가 공개될 위험이 있기 때문입니다. 이를 방지하기 위해, 비밀번호는 원본 텍스트를 알 수 없는 형태로 변환한 후에 데이터베이스에 저장합니다. 이런 변환 과정을 '암호화'라고 합니다.

PasswordEncoder 인터페이스에는 주로 두 가지 메서드가 있습니다:

encode(CharSequence rawPassword): 입력받은 원본 비밀번호를 암호화하여 반환합니다.
matches(CharSequence rawPassword, String encodedPassword): 원본 비밀번호와 암호화된 비밀번호를 비교하여 일치하는지 확인합니다. 로그인 과정에서 이 메서드를 사용하여 사용자가 입력한 비밀번호와 데이터베이스에 저장된 암호화된 비밀번호를 비교합니다.

어노테이션을 이용한 권한 체크

로그인 처리가 되는 것을 확인했다면 특정한 경로에 시큐리티를 적용해 보도록 하자. 예를 들어 게시물 목록은 로그인 여부에 관계없이 볼 수 있지만 게시물의 글쓰기는 로그인한 사용자만 접근할 수 있어야만 한다.
이처럼 특정 경로에 접근할 수 있는 권한을 설정하는 작업은 코드로 설정할 수도 있고, 어노테이션을 이용해서 지정할 수도 있다. 코드로 설정하는 경우 매번 컨트롤러의 메소드를 작성한 후 다시 설정을 조정해야하는 불편함이 있으므로 어노테이션을 이용하는 방식을 알아보자.

@EnableMethodSecurity

어노테이션으로 권한을 설정하려면 설정 관련 클래스에 @EnableMethodSecurity 어노테이션을 추가해 주어야한다.
CustomSecurityConfig 클래스에 다음과 같이 추가해준다.

@EnableMethodSecurity의 prePostEnabled 속성은 원하는 곳에 @PreAuthorize 혹은 @PostAuthorize 어노테이션을 이용해서 사전 혹은 사후의 권하을 체크할 수 있다.

@PreAuthorize 안에는 표현식을 이용해서 특정한 권한을 가진 사용자만이 접근 가능하도록 지정할 수 있다.
이제 '/register'로 이동하면 @PreAuthorize에 막혀서 로그인 페이지로 이동하게 된다. 스프링 시큐리티는 로그인이 필요해서 로그인 페이지로 리다이렉트하는 경우 어디에서부터 로그인 페이지로 이동했는지를 저장하기 때문에 로그인 후에는 해당 경로로 자동 이동한다.

현재 'ROLE_USER'라는 인가(Authority)를 가지도록 코드가 작성되어 있으므로 로그인이 처리되면 해당 사용자는 @PreAuthorize에서 'hasRole('USER')'라는 표현식 값이 true가 된다.

커스텀 로그인 페이지

스프링 시큐리티는 별도의 페이지를 생성하지 않아도 자동으로 로그인 페이지를 제공하기는 하지만 화면의 디자인을 반영할 수 없기 때문에 별도의 로그인 페이지를 만들어서 사용하는 것이 더 일반적이다. 이에 대한 설정은 CustomSecurityConfig를 수정해서 처리한다.

HttpSecurity의 formLogin() 관련해서 loginPage()를 지정하면 로그인이 필요한 경우에 '/member/login' 경로로 자동 리다이렉트 된다.

커스텀 로그인 화면

앞의 코드를 실행하고 로그인을 시도하면 403 에러가 발생하는데, 스프링 시큐리티는 기본적으로 GET방식을 제외한 요청에 CSRF토큰을 요구하기 때문이다. 따라서 CustomSecurityConfig에서 CSRF토큰을 비활성화해주도록 하겠다.

CSRF토큰을 비활성화하면 username과 password라는 파라미터만으로 로그인이 가능해진다.

앞의 동작 방식에서 중요한 점은 실제 로그인 처리를 수행하는 POST 방식에 대한 코드를 작성하지 않았다는 점이다. CustomSecurityConfig에 로그인의 처리 경로를 http.formLoginPage("/member/login")와 같이 지정하면 POST 방식 처리 역시 같은 경로로 스프링 시큐리티 내부에서 처리된다.

로그아웃 처리 설정

스프링 시큐리티는 기본적으로 HttpSession을 이용해서 처리되기 때문에 로그아웃은 세션을 유지하는데 사용하는 쿠키(톰캣의 경우 JSESSIONID 쿠키)를 삭제하면 자동으로 로그아웃이 된다.
스프링 시큐리티에서는 기본적으로 '/logout'이라는 경로를 제공하는데 CSRF토큰이 비활성화 되는 경우에는 GET방식으로도 로그아웃이 가능하다. 만일 현재 로그인한 사용자가 '/logout'이라는 경로를 호출하면 다음 그림과 같이 로그인 경로로 이동하는 것을 볼 수 있다.

remember-me 기능 설정

스프링 시큐리티의 'remember-me(자동 로그인)'기능은 쿠키를 이용해서 브라우저에 로그인 했던 정보를 유지하기 때문에 매번 로그인을 실행할 필요가 없어진다. 기존의 로그인 유지 방법인 Http Session을 이용했던 것과 달리 remember-me는 쿠키에 유효 기간을 지정해서 쿠키를 브라우저가 보관하게 하고 쿠키의 값인 특정한 문자열을 보관시켜서 로그인 관련 정보를 유지하는 방식이다. 스프링 시큐리티의 remember-me를 이용하는 방법은 설정을 변경하는 것만으로 가능한데, 지금 상황과 같이 커스텀 로그인 페이지를 만드는 경우에는 약간의 추가 설정이 필요하다.

테이블 생성

remember-me의 쿠키값을 생성할 때 필요한 정보들을 보관하기 위해서 여러 방법이 존재하겠지만 가장 무난한 방식은 데이터베이스를 이용하는 것이다. 현재 프로젝트가 사용하는 데이터베이스에 다음과 같은 persistent_logins라는 이름의 테이블을 생성해두자.

이 테이블의 이름은 스프링 시큐리티의 내부에서 사용하기 때문에 변경하지 않도록 주의해야한다.

자동 로그인을 위한 설정 변경

remember-me 기능의 설정은 쿠키를 발행하도록 CustomSecurityConfig의 내용을 수정해서 처리할 수 있다. 이때 쿠키와 관련된 정보를 테이블로 보관하도록 지정하는데 DataSource가 필요하고 UserDetailServie 타입의 객체가 필요하다.


자동 로그인을 위한 화면 설정

remember-me를 활성화 하기 위해서는 로그인 화면에서 'remember-me'라는 이름의 값이 같이 전달되어야한다.

앞의 설정이 반영된 후에 사용자가 로그인하면 'remember-me'라는 이름의 쿠키가 30일의 유효 기간을 가지고 생성되는 것을 확인할 수 있다.

쿠키가 정상적으로 생성된 것을 확인했다면 브라우저를 종료한 후에 다시 '/board/register'를 호출해서 자동으로 로그인된 것을 확인할 수 있다.

데이터베이스에 persistent_logins를 조회하면 다음과 같이 user1 계정의 'remember-me' 쿠키가 유지되고 있다는 것을 확인할 수 있다. 이때 '/logout'으로 이동하면 테이블의 값이 자동으로 삭제된다.

@PreAthuorize 추가


회원 데이터 처리

스프링 시큐리티에서 실제 사용자의 정보 로딩은 UserDetailService를 이용해서 처리된다. Spring Data JPA를 이용해서 실제 사용할 수 있는 회원 데이터를 구성하도록 하자.

회원 도메인과 Repository

데이터베이스에 회원 데이터를 다음과 같은 속성들로 구성할 것이다.

• 회원 아이디(mid)
• 패스워드(mpw)
• 이메일(email)
• 탈퇴여부(del)
• 등록일/수정일(regDate, modDate)
• 소셜 로그인 자동 회원 가입 여부(social)

소셜 로그인으 경우 별도의 회원 가입 없이 소셜 서비스에서 인증된 사용자의 이메일을 회원 아이디로 간주하고 회원 데이터를 추가하도록 구성한다.(나중에 회원 수정에서 패스워드 등을 수정하도록 구성)
각 회원은 'USER' 혹은 'ADMIN' 권한을 가질 수 있도록 @ElementCollection으로 처리한다.

MemberRepository와 테스트 코드

일반회원 추가 테스트


직접 로그인할 때는 소셜 서비스를 통해서 회원 가입된 회원들이 같은 패스워드를 가지므로 일반 회원들만 가져오도록 social 속성값이 false인 사용자들만 대상으로 처리한다.

회원 조회 테스트

@Test
    public void testRead() {

        Optional<Member> result = memberRepository.getWithRoles("member100");

        Member member = result.orElseThrow();

        log.info(member);
        log.info(member.getRoleSet());

        member.getRoleSet().forEach(memberRole -> log.info(memberRole.name()));

    }

회원 서비스와 DTO 처리

도메인으로 회원은 특별한 점이 없지만 시큐리티를 이용하는 경우 회원 DTO는 해당 API에 맞게 작성되어야하기 때문에 달라지는 부분이 많아진다. 스프링 시큐리티에서는 UserDetails라는 타입을 이용하기 때문에 일반적인 DTO와 조금 다르게 처리해야 할 필요가 있다.

MemberSecurityDTO

@Getter
@Setter
@ToString
public class MemberSecurityDTO extends User {

    private String mid;
    private String mpw;
    private String email;
    private boolean del;
    private boolean social;


    public MemberSecurityDTO(String username, String password, String email, boolean del, boolean social,
                             Collection<? extends GrantedAuthority> authorities) {
        super(username, password, authorities);

        this.mid = username;
        this.mpw = password;
        this.email = email;
        this.del = del;
        this.social = social;

    }
}

MemberSecurityDTO 클래스는 User라는 클래스를 부모 클래스로 사용한다. User 클래스는 UserDetails 인터페이스를 구현한 클래스로 최대한 간단하게 UserDetails 타입을 생성할 수 있는 방법을 제공한다.

CustomUserDetailsService의 수정

실제 로그인 처리를 담당하는 CUstomUserDetailService는 MemberRepository를 주입 받아서 로그인에 필요한 MemberSecurityDTO를 반환하도록 수정한다.

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

    private final MemberRepository memberRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        log.info("loadUserByUsername: " + username);

        Optional<Member> result = memberRepository.getWithRoles(username);

        if (result.isEmpty()) { // 해당 아이디를 가진 사용자가 없다면
            throw new UsernameNotFoundException("username not found...");
        }

        Member member = result.get();

        MemberSecurityDTO memberSecurityDTO =
                new MemberSecurityDTO(
                        member.getMid(),
                        member.getMpw(),
                        member.getEmail(),
                        member.isDel(),
                        false,
                        member.getRoleSet()
                                .stream().map(memberRole -> new SimpleGrantedAuthority("ROLE_" + memberRole.name()))
                                .collect(Collectors.toList())
                );

        log.info("memberSecurityDTO");
        log.info(memberSecurityDTO);

        return memberSecurityDTO;
    }
}

회원 가입 처리

지금까지 작성된 예제는 데이터베이스에 이미 추가된 계정으로만 로그인이 가능하므로 회원 가입을 통해서 사용자가 직접 계정을 생성할 수 있도록 구성한다.

회원 가입을 위한 DTO

회원 가입 서비스 계층 처리


MemberController의 변경

회원 가입 결과

소셜 로그인 처리

카카오 OAuth2 서비스를 이용해보자.

프로젝트를 위한 설정

'kakao developers'에 들어가 다음의 과정들을 수행해준다.








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

소셜 로그인이 처리되는 과정 OAuth2

대부분의 소셜 로그인은 OAuth2 방식으로 데이터를 처리한다.
OAuth2는 문자열로 구성된 '토큰(token)'을 주고받는 방식으로, 토큰을 발행하거나 검사하는 방식을 통해서 서비스 간 데이터를 교환한다.
카카오의 경우에만 집중해서 살펴보면 크게 3단계로 구성된다.



다음 그림에서 스텝 1의 과정은 놀이 공원에서 '입장권'을 사는 것과 유사하다. 설정할 때 받은 'REST API키'를 이용해서 인가 코드를 받는다.
인가 코드는 'Redirect URI'로 지정된 곳으로 전달된다. 스프링 부트의 OAuth2 Client를 이용하는 경우에는 'Redirect URI'는 정해진 패턴을 사용하게 된다.
스텝 1에서 인가받은 코드는 스텝 2에서 자신의 비밀키(Client Secret)와 같이 이용되어 Access Token을 생성할 때 사용된다.
Access Token은 놀이공원에 있는 특정한 놀이 기구를 타기 위한 승차권과 같다. Acess Token은 말 그대로 '원하는 데이터에 접근할 수 있는 권한'역할을 한다.
Acccess Token을 얻으면 이를 이용해서 스텝 3과 같이 사용자 정보를 요청한다. 이때 사용자가 동의했던 정보들을 얻어오는데 주로 이메일을 얻어오는 경우가 대부분이다.

스프링 부트에서 로그인 연동 설정

카카오 로그인은 스프링 부트에서 약간의 설정만으로 구현이 가능하다. application properties에 다음과 같은 내용들을 추가한다.

spring.security.oauth2.client.provider.kakao.authorization-uri=https://kauth.kakao.com/oauth/authorize
spring.security.oauth2.client.provider.kakao.user-name-attribute=id
spring.security.oauth2.client.provider.kakao.token-uri=https://kauth.kakao.com/oauth/token
spring.security.oauth2.client.provider.kakao.user-info-uri=https://kapi.kakao.com/v2/user/me

spring.security.oauth2.client.registration.kakao.client-name=kakao
spring.security.oauth2.client.registration.kakao.authorization-grant-type=authorization_code
spring.security.oauth2.client.registration.kakao.redirect_uri=http://localhost:8080/login/oauth2/code/kakao
spring.security.oauth2.client.registration.kakao.client-id=REST키


spring.security.oauth2.client.registration.kakao.client-secret=설정된 비밀키
spring.security.oauth2.client.registration.kakao.client-authentication-method=client_secret_post
spring.security.oauth2.client.registration.kakao.scope=profile_nickname,account_email

CustomSecurityConfig설정 변경

스프링 부트의 OAuth2 Client를 이용할 때는 설정 관련 코드에 OAuth2 로그인을 사용한다는 설정을 추가해 주어야 한다.

로그인 연동 후 이메일 구하기

앞선 과정을 통해서 카카오 서비스의 로그인까지 성공해도 로그인된 후에 전달하는 정보가 UserDetails 타입이 아니기 때문에 문제가 발생한다. 이를 처리하려면 UserDetailsService 인터페이스를 구현하듯이 OAuth2UserService 인터페이스를 구현해야 한다. OAuth2UserService 인터페이스는 그 자체를 구현할 수도 있겠지만 하위 클래스인 DefaultOAuth2UserService를 상속해서 구현하는 방식이 가장 간단하다.

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

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        log.info("userRequest....");
        log.info(userRequest);

        log.info("oauth2 user....................................");

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

        log.info("NAME: " + clientName);

        OAuth2User oAuth2User = super.loadUser(userRequest);
        Map<String, Object> paramMap = oAuth2User.getAttributes();

        String email = null;

        switch (clientName) {
            case "kakao":
                email = getKakaoEmail(paramMap);
                break;
        }

        log.info("==================================");
        log.info(email);
        log.info("==================================");

        return oAuth2User;
    }

    private String getKakaoEmail(Map<String, Object> paramMap) {
        log.info("KAKAO----------------------------------------");
        Object value = paramMap.get("kakao_account");
        log.info(value);

        LinkedHashMap accountMap = (LinkedHashMap) value;

        String email = (String) accountMap.get("email");

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

        return email;
    }

}

DefaultOAuth2UserService의 loadUser()의 리턴 타입은 OAuth2User인데, loadUser()에서는 카카오 서비스와 연동된 결과를 OAuth2UserRequest로 처리하기 때문에 이를 이용해서 원하는 정보(이메일)을 추출해야 한다.
그리고 DefaultOAuth2UserService는 카카오뿐 아니라 구글이나 페이스북 등의 다양한 소셜 로그인에서 사용 가능하므로 이를 각각의 소셜 서비스에 맞게 처리하는 로직과 메서드를 추가해주었다.

소셜 로그인 후처리

소셜로그인 처리 자체는 약간의 설정이 추가되는 수준이지만 정작 복잡한 결정은 소셜 로그인한 사용자에 대한 처리이다. 소셜 로그인에 사용한 이메일이 존재하는 경우와 그렇지 않은 경우에 어떻게 처리할 것인지 결정이 필요하다.
만일 소셜 로그인에 사용한 이메일과 같은 이메일을 가진 회원이 있다면 소셜 로그인만으로 로그인 자체가 완료되어야한다. 이에 대한 처리는 MemberSecurityDTO를 UserDetails 인터페이스뿐만 아니라 OAuth2User 인터페이스도 같이 사용할 수 있도록 구성하면 된다. 이때 가장 중요한 속성이 social 속성이다. 자동으로 회원 데이터가 추가될 때는 social 속성값을 true로 지정한다. 만일 악의적인 사용자가 현재 사용자의 이메일을 안다고 해도 직접 로그인을 할 때는 social 설정이 false인 경우만 조회되므로 로그인이 되지 않는다.

MemberRepository

MemberSecurityDTO
@Getter
@Setter
@ToString
public class MemberSecurityDTO extends User implements OAuth2User {

    private String mid;
    private String mpw;
    private String email;
    private boolean del;
    private boolean social;

    private Map<String, Object> props; // 소셜 로그인 정보


    public MemberSecurityDTO(String username, String password, String email, boolean del, boolean social,
                             Collection<? extends GrantedAuthority> authorities) {
        super(username, password, authorities);

        this.mid = username;
        this.mpw = password;
        this.email = email;
        this.del = del;
        this.social = social;
    }

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

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

MemberSecurityDTO가 OAUth2User 인터페이스를 추가로 구현해도 실제로 필요한 메소드는 getAttrbutes()와 getName()정도이다.

CustomOAuth2UserService 수정

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

    private final MemberRepository memberRepository;
    private final PasswordEncoder passwordEncoder;

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        log.info("userRequest....");
        log.info(userRequest);

        log.info("oauth2 user....................................");

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

        log.info("NAME: " + clientName);

        OAuth2User oAuth2User = super.loadUser(userRequest);
        Map<String, Object> paramMap = oAuth2User.getAttributes();

        String email = null;

        switch (clientName) {
            case "kakao":
                email = getKakaoEmail(paramMap);
                break;
        }

        log.info("==================================");
        log.info(email);
        log.info("==================================");

        return generateDTO(email, paramMap);
    }

    private OAuth2User generateDTO(String email, Map<String, Object> params) {
        Optional<Member> result = memberRepository.findByEmail(email);

        //데이터베이스에 해당 이메일을 사용자가 없다면
        if(result.isEmpty()){
            //회원 추가 -- mid는 이메일 주소/ 패스워드는 1111
            Member member = Member.builder()
                    .mid(email)
                    .mpw(passwordEncoder.encode("1111"))
                    .email(email)
                    .social(true)
                    .build();
            member.addRole(MemberRole.USER);
            memberRepository.save(member);

            //MemberSecurityDTO 구성 및 반환
            MemberSecurityDTO memberSecurityDTO =
                    new MemberSecurityDTO(email, "1111",email,false, true, Arrays.asList(new SimpleGrantedAuthority("ROLE_USER")));
            memberSecurityDTO.setProps(params);

            return memberSecurityDTO;
        }else {
            Member member = result.get();
            MemberSecurityDTO memberSecurityDTO =
                    new MemberSecurityDTO(
                            member.getMid(),
                            member.getMpw(),
                            member.getEmail(),
                            member.isDel(),
                            member.isSocial(),
                            member.getRoleSet()
                                    .stream().map(memberRole -> new SimpleGrantedAuthority("ROLE_"+memberRole.name()))
                                    .collect(Collectors.toList())
                    );

            return memberSecurityDTO;
        }
    }

    private String getKakaoEmail(Map<String, Object> paramMap) {
        log.info("KAKAO----------------------------------------");
        Object value = paramMap.get("kakao_account");
        log.info(value);

        LinkedHashMap accountMap = (LinkedHashMap) value;

        String email = (String) accountMap.get("email");

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

        return email;
    }

}

generateDTO()는 이미 회원 가입이 된 회원에 대해서는 기존 정보를 반환하고 새롭게 소셜 로그인된 사용자는 자동으로 회원 가입을 처리하게 된다. 소셜 로그인 사용자의 패스워드가 '1111'로 고정되기는 하지만 일반 로그인을 통해서는 소셜 사용자는 로그인이 제한된다. 그러므로 소셜 로그인 사용자는 소셜 로그인으로 로그인 한 후에 사용자 정보를 일반 회원으로 수정하도록 해야한다.

AuthenticationSuccessHandler를 이용한 로그인 후처리

스프링 시큐리티는 로그인 성공과 실패를 커스터마이징할 수 있도록 AuthenticationSuccessHandler와 AuthentucaionFailehandler 인터페이스를 제공한다. 예제에서는 소셜 로그인 성공 후에 현재 사용자의 패스워드에 따라서 사용자 정보를 수정하거나 특정한 페이지로 이동하는 방법을 처리해야 하는데 AuthenticationSuccessHandler를 이용해서 이를 처리하도록 한다.

@Log4j2
@RequiredArgsConstructor
public class CustomSocialLoginSuccessHandler implements AuthenticationSuccessHandler {

    private final PasswordEncoder passwordEncoder;

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

        log.info("----------------------------------------------------------");
        log.info("CustomLoginSuccessHandler onAuthenticationSuccess ..........");
        log.info(authentication.getPrincipal());

        MemberSecurityDTO memberSecurityDTO = (MemberSecurityDTO) authentication.getPrincipal();

        String encodedPw = memberSecurityDTO.getMpw();

        //소셜로그인이고 회원의 패스워드가 1111이라면
        if (memberSecurityDTO.isSocial()
                && (memberSecurityDTO.getMpw().equals("1111")
                ||  passwordEncoder.matches("1111", memberSecurityDTO.getMpw())
        )) {
            log.info("Should Change Password");

            log.info("Redirect to Member Modify ");
            response.sendRedirect("/member/modify");

            return;
        } else {

            response.sendRedirect("/board/list");
        }
    }
}

시큐리티 설정에 CustomSocialLoginSuccessHandler를 추가해야 하므로 CustomSecurityConfig를 수정해야한다.

MemberRepository의 패스워드 업데이트

소셜 로그인으로 로그인하면 무조건 패스워드가 '1111'을 인코딩한 값으로 저장되므로 패스워드가 변경된 상황에서의 테스트를 해보겠다.
우선 MemberRepository에 다음의 코드를 추가한다.

테스트 코드


소셜 로그인으로 로그인하면 정상적으로 이동하는 것을 확인할 수 있다.

0개의 댓글