BackEnd/Spring Security

[Spring Security] 스프링 시큐리티로 OAuth 로그인 구현하기

샤아이인 2022. 5. 10.

이번 시간에는 삽질을 하면서 구현한 OAuth 로그인에 대하여 정리하는 글 입니다.

 

이번 글 에서는 Spring Security를 활용하여 로그인을 구현해보려 한다.

기본 id, password 로그인 방식 + OAuth 로그인 방식을 동시에 구현할 것 이다!

 

기회가 된다면 다음번 글로 Spring Security 없이 로그인 하는 글 또한 올려보겠다.

 

1. 사전 구현 준비

1-1. 의존성 추가하기

우선 의존성은 다음과 같다. 

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-mustache'
    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
    compileOnly 'org.projectlombok:lombok'
    developmentOnly 'org.springframework.boot:spring-boot-devtools'
    runtimeOnly 'mysql:mysql-connector-java'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'org.springframework.security:spring-security-test'
}

자바는 11버전을 사용하였다.

 

1-2. User Domain 만들기

저장할 사용자의 domain은 다음과 같다.

@Data
@Entity
public class User {

    @Id
    @GeneratedValue
    @Column(name = "user_id")
    private Long id;

    private String username;
    private String password;
    private String email;

    @Enumerated(EnumType.STRING)
    private Role role; //ROLE_USER, ROLE_ADMIN

    private String provider;
    private String providerId;

    public User() {
    }

    public User(String username, String password, String email, Role role) {
        this.username = username;
        this.password = password;
        this.email = email;
        this.role = role;
    }

    @Builder
    public User(String username, String password, String email, Role role, String provider, String providerId) {
        this.username = username;
        this.password = password;
        this.email = email;
        this.role = role;
        this.provider = provider;
        this.providerId = providerId;
    }

    @CreationTimestamp
    private Timestamp createDate;
}


--------------------------------------------------------------------------------
public enum Role {
    ROLE_USER, ROLE_MANAGER, ROLE_ADMIN
}

원래 @Data 는 사용을 지양하는 것 이 좋지만... 일단 구현의 편함을 위해 사용하였다.

 

1-3. UserRepository 만들기

public interface UserRepository extends JpaRepository<User, Long> {
    public User findByUsername(String username);
}

Spring Data JPA를 사용하여 repository를 만들었다.

추가로 쿼리메소드로 findByUsername() 또한 만들어 주었다.

 

1-4. IndexController 만들기

컨트롤러에 기본적인 몇가지 함수만 만들어보자!

@Slf4j
@Controller
@RequiredArgsConstructor
@RequestMapping("")
public class IndexController {

    private final UserService userService;

    @ResponseBody
    @GetMapping
    public String index() {
        return "index";
    }

    @ResponseBody
    @GetMapping("/user")
    public String user(@AuthenticationPrincipal PrincipalDetails principalDetails) {
        log.info("[PrincipalDetails] : {}", principalDetails.getUser());
        return "user";
    }

    @ResponseBody
    @GetMapping("/admin")
    public String admin() {
        return "admin";
    }

    @ResponseBody
    @GetMapping("/manager")
    public String manager() {
        return "manager";
    }

    @GetMapping("/loginForm")
    public String loginForm() {
        return "loginForm";
    }

    @GetMapping("/joinForm")
    public String joinForm() {
        return "joinForm";
    }

    @PostMapping("/join")
    public String joinUser(@ModelAttribute JoinRequest joinRequest) {
        log.info("[Join Request] : {}", joinRequest);
        userService.joinUser(joinRequest.getUsername(), joinRequest.getPassword(), joinRequest.getEmail());
        return "redirect:/loginForm";
    }
}

joinUser에서 사용한 request용 Dto인 JoinRequest는 다음과 같다.

@Data
public class JoinRequest {
    private final String username;
    private final String password;
    private final String email;
}

또한 로그인과 가입에 사용되는 loginForm, joinForm은 다음과 같다.

-------------------------------loginForm--------------------------------
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <title>로그인 페이지</title>
</head>
<body>
<h1>로그인 페이지</h1>
<hr/>
<!-- 시큐리티는 x-www-form-url-encoded 타입만 인식 -->
<form action="/login" method="post">
  <input type="text" name="username" placeholder="Username"/> <br/>
  <input type="password" name="password" placeholder="Password"/> <br/>
  <button>로그인</button>
</form>
<a href="/oauth2/authorization/google">Google Login</a> <br/>
<a href="/oauth2/authorization/facebook">FaceBook Login</a> <br/>
<a href="/oauth2/authorization/naver">Naver Login</a> <br/>
<a href="/joinForm">회원가입</a>
</body>
</html>


-------------------------------joinForm--------------------------------
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <title>회원가입 페이지</title>
</head>
<body>
<h1>회원가입 페이지</h1>
<hr/>
<form action="/join" method="post">
  <input type="text" name="username" placeholder="Username"/> <br/>
  <input type="password" name="password" placeholder="Password"/> <br/>
  <input type="email" name="email" placeholder="Email"/> <br/>
  <button>회원가입</button>
</form>
</body>
</html>

 

1-5. UserService 만들기

@RequiredArgsConstructor
@Service
public class UserService {

    private final UserRepository userRepository;
    private final BCryptPasswordEncoder bCryptPasswordEncoder;

    public User joinUser(String username, String password, String email) {
        String encodedPwd = encodePassword(password);
        User newUser = new User(username, encodedPwd, email, Role.ROLE_USER);
        return userRepository.save(newUser);
    }

    private String encodePassword(String originPwd) {
        return bCryptPasswordEncoder.encode(originPwd);
    }
}

이정도면 기본 설정은 끝이 났다.

 

하지만 아직 동작하지는 않을 것 이다. 위 내용이 이해되지 않아도 OAuth 를 구현하면서 필요한 내용을 다시 설명하겠다!

일단 초기 설정이라 생각해 달라!

 

2. Google API 사전 등록

2-1. 새 프로젝트 만들기

https://console.cloud.google.com/home/dashboard 으로 접속해서 "새 프로젝트" 버튼을 눌러 프로젝트를 만든다.

 

2-2. 사용자 인증 정보 만들기

생성된 프로젝트에서 API 및 서비스를 클릭한다.

 

이후 사용자 인증 정보 만들기를 클릭해준다!

이후 클라이언트의 이름과, redirect_URI 를 지정해 준다.

 

이때 핵심이 redirect_URI이다!

 

Spring Security는 기본적으로 인식해주는 redirect_uri 의 형식이 있다.

http://localhost:8080/login/oauth2/code/{provider 이름}

따라서 유명한 Google, Facebook 등은 이미 Spring Security에서 oauth2client를 사용할때 편하게 사용하도록 도와준다.

위와 같이 정해진 형식대로 redirect_URI를 지정해 주자!

 

이렇게 생성하고 나면 Client_ID 와 Client_Password 가 발급된다.

중요한 정보이니 따로 잘 저장해 두자!

 

2-2. application.yml 에 client 정보 등록하기

spring:
  security:
    oauth2:
      client:
        registration:
          google:
            client-id: 329044829160-85sv767itqcd4iueaqqms5mo4qgehtc0.apps.googleusercontent.com
            client-secret: {비밀번호}
            scope:
              - email
              - profile

          facebook:
            client-id: 722320342150186
            client-secret: {비밀번호}
            scope:
              - email
              - public_profile

          naver:
            client-id: 2mGpk1ksfsX8CiBV33Zg
            client-secret: {비밀번호}
            scope:
              - name
              - email
            client-name: Naver
            authorization-grant-type: authorization_code
            redirect-uri: http://localhost:8080/login/oauth2/code/naver # {baseUrl}/{action}/oauth2/code/{registrationId}

        provider:
          naver:
            authorization_uri: https://nid.naver.com/oauth2.0/authorize
            token_uri: https://nid.naver.com/oauth2.0/token
            user-info-uri: https://openapi.naver.com/v1/nid/me
            user_name_attribute: response

google, facebook 같은 경우 Spring OAuth Client 에서 지원하는 회사들 이기 때문에 따로 resdirect-uri 등을 설정할 필요가 없다.

하지만 국내 회사인 naver 같은 경우 OAuth-Client에 등록된 회사가 아니기 때문에 따로 지정해 주어야 한다.

따라서 추가적으로 provider 에 대한 정보를 추가해 주었다.

 

사전 작업이 끝났다! 이제 스프링 시큐리티를 이용해 OAuth 로그인을 구현하자!

 

3. OAuth 로그인 구현

3-1. SecurityConfig

Spring Security가 제공해주는 설정들을 사용하기 위해서는 WebSecurityConfigurerAdapter를 상속받아 configure메서드를 override를 해줘야한다.

 

spring security는 OAuth2 로그인 말고도, csrf, url 별 권한 관리 등 여러 설정을 제공해준다.

 

우리의 설정에서는 csrf 설정을 off시켜줄 것 이다.

csrf가 켜져 있으면 form 태그로 요청시에 csrf 속성이 추가되기 때문에 서버쪽에서 만들어준 form 태그로만 요청이 가능하게 됩니다.

이렇게 되면 postman 요청이 불가능해지기 때문에 disable() 시켰다.

 

또한 우리는 OAuth 로그인과 더불어, 일반적인 is, pwd 방식의 로그인도 지원할 것 이기 때문에 둘에 대한 설정을 동시에 진행해 주었다.

주석으로 설명을 추가해두었으니 읽어보길 권장한다!

@Configuration
@EnableWebSecurity // Spring Security filter에 등록시킨다.
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    private PrincipleOauth2UserService principleOauth2UserService;

    public SecurityConfig(PrincipleOauth2UserService principleOauth2UserService) {
        this.principleOauth2UserService = principleOauth2UserService;
    }

    @Bean // 비밀번호 암호화를 위해 Bean으로 등록
    public BCryptPasswordEncoder encodePwd() {
        return new BCryptPasswordEncoder();
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.csrf().disable(); // postman으로 요청하기 위해 csrf 차단 옵션 끄기
        
        http.authorizeRequests()
                .antMatchers("/user/**").authenticated() // 인증이 필요
                .antMatchers("/manager/**").access("hasRole('ROLE_ADMIN') or hasRole('ROLE_MANAGER')") // 인증 + 인가
                .antMatchers("/admin/**").access("hasRole('ROLE_ADMIN')") // 인증 + 인가
                .anyRequest().permitAll() // 그 외의 다른 요청은 수용한다.
                .and()
                .formLogin() // 권한이 없으면 로그인 페이지로 이동
                .loginPage("/loginForm") // 기본 로그인 페이지
                .loginProcessingUrl("/login") // /login URL 호출시 Spring Security 가 대신 진행됨
                .defaultSuccessUrl("/") // 로그인 성공시 이동할 URL
                .and()
                .oauth2Login() // OAuth2 로그인 설정 시작점
                .loginPage("/loginForm")
                .userInfoEndpoint() // OAuth2 로그인 성공 이후 사용자 정보를 가져올 때 설정 담당
                .userService(principleOauth2UserService); // OAuth2 로그인 성공 시, 후작업을 진행할 UserService 인터페이스 구현체 등록
                                                          // AUtorization code가 아닌, AccessToken, 사용자 정보를 바로 받음
    }
}

약간의 설명을 더해보자면

 

1) 로그인 처리 (인증)

지금 우리 서버에 "/login"으로 요청이 오면 Spring Security가  대신 로그인 처리를 진행한다.

이는 위 설정에서 loginProcessingUrl("/login") 을 등록해두었기 때문이다.

따라서 '/login' 요청이 오면 자동으로 UserDetailsService 타입인 PrincipalDetailsService bean의 loadUserByUsername 함수 호출된다.

이때 loadUserByUsername 함수를 통해 Authentication 객체에 필요한 정보를 생성한다.

=> 기본 로그인 사용자 생성

 

또한 userService()를 통해 후작업을 진행할 service를 등록해두었기 때문에 OAuth 로그인에 성공한 유저 정보를PrincipalOauth2UserService 를 통해 후처리를 하게 된다.

=> OAuth 로그인 사용자 생성

 

이에 대한 구현은 뒤에서 하겠다.

 

2) Authentication 객체 생성

인증에 성공한 사용자는 Authentication 인증 객체를 만들어 내부에 principal, credentials, authorities 정보를 채운다.

 

3) Authentication를 SecurityContextHolder 안의 SecurityContext에 저장한다.

Authentication를 전역으로 접근할 수 있게 됩니다.

 

SecurityContextHolder는 ThreadLocal 에 저장되기 때문에 각기 다른 thread 별로 자신의 SecurityContextHolder 인스턴스를 가지고 있기 때문에 사용자 별 Authentication를 가지고 있게 됩니다.

 

SecurityContext 에는 저장될 수 있는 객체의 타입이 정해져 있는데, 바로 Authentication 타입의 객체이다.

해당 Authentication 타입 안에 User에 대한 정보가 담겨 있다.

 

3-2. PrincipalDetails 구현하기

우선 Authentication에 저장할 PrincipalDetails 라는 객체를 만들것 이다.

 

문제는 일반 사용자와, OAuth 사용자의 type이 다르다는 점 이다...

 

일반 로그인한 사용자의 경우 UserDetails 타입이 되고, OAuth 사용자의 경우 Oauth2User 타입을 반환하게 된다.

타입이 2종류가 되면 Controller에서 인자로 전달받을때 타입을 명시해주기 어렵다.

분기문을 타면서 casting을 해주면 되기는 하지만... 그보다 한번에 처리 가능하도록 하고싶다.

따라서 UserDetails 과 Oauth2User 타입을 모두 구현하는 PrincipalDetails 타입을 만들게 되었다.

@Getter
public class PrincipalDetails implements UserDetails, OAuth2User {

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

    // 일반 로그인용
    public PrincipalDetails(User user) {
        this.user = user;
    }

    // OAuth 로그인용
    public PrincipalDetails(User user, Map<String, Object> attributes) {
        this.user = user;
        this.attributes = attributes;
    }

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

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

    // 해당 User의 권한을 반환
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        Collection<GrantedAuthority> collection = new ArrayList<>();
        collection.add((GrantedAuthority) () -> user.getRole().name());
        return collection;
    }

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

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

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

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

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

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

 

3-3. PrincipalDetailsService 구현하기

이번에는 Service를 구현해 보자!

@RequiredArgsConstructor
@Service
public class PrincipalDetailsService implements UserDetailsService {

    private final UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        Optional<User> optionalUser = userRepository.findByUsername(username);
        if(optionalUser.isPresent()) {
            return new PrincipalDetails(optionalUser.get()); // 반환된 UserDetails은 Authentication으로 들어감
        }
        return null;
    }
}

UserDetailService를 구현하면 loadUserByUsername 메서드를 오버라이딩 해줘야 한다.

넘겨 받은 username에 해당되는 user가 있다면 PrinciplaDetails에 담아서 반환해주면 된다.

해당되는 user가 없다면 null값을 반환해준다.

 

해당 메서드는 기본로그인 방식을 통해 로그인 할때 실행될 것 이다.

 

3-4. PrincipleOauth2UserService 구현하기

OAuth 로그인에 성공한 user의 후처리 작업을 해주는 service 이다.

OAuth 로그인을 하게될 경우 실행되는 Service이다.

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

    private final UserRepository userRepository;

    // Google로부터 받은 userRequest 데이터 후처리 하기
    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        // code를 OAuth-client가 받아 Access_Token을 요청후 받음 => userRequest
        // userRequest로 회원 정보를 얻어오기 위해 loadUser() 호출
        OAuth2User oAuth2User = super.loadUser(userRequest);

        log.info("[UserService AccessToken] : {}", userRequest.getAccessToken().getTokenValue());
        log.info("[UserService getClientRegistration] : {}", userRequest.getClientRegistration());
        log.info("[UserService oAuth2User attributes] : {}", oAuth2User.getAttributes());

        OAuth2UserInfo userInfo = OAuthProvider.extract(userRequest.getClientRegistration().getRegistrationId(), oAuth2User.getAttributes());

        // 회원가입
        String provider = userInfo.getProvider();
        String providerId = userInfo.getProviderId();
        String username = provider + "_" + providerId;
        String email = userInfo.getEmail();

        User user = userRepository.findByUsername(username).orElseGet(() -> {
            User newUser = User.builder()
                    .provider(provider)
                    .providerId(providerId)
                    .username(username)
                    .email(email)
                    .role(Role.ROLE_USER)
                    .build();
            return userRepository.save(newUser);
        });

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

먼저 userRequest를 통해 OAuth 서비스에서 가져온 유저 정보를 담고 있는 OAuth2User를 가져온다.

 

userRequest에는 access_token에 대한 정보가 담겨 있다.

해당 정보를 통해 loadUser(userRequest)를 실행하면, 해당 회원 정보가 담긴 oAuth2User 객체를 반환한다.

OAuth2User oAuth2User = super.loadUser(userRequest);

 

이렇게 받은  OAuth2User를 통해 데이터를 Map<String, Object>로 뽑아낸 후, OAuth2UserInfo라는 interface에 담아낼 것 이다.

 

3-5. OAuth2UserInfo 인터페이스 만들기

공통된 OAuth2UserInfo interface를 만들고, 이를 각 회사별로 적합한 구현체를 만들것 이다.

 

우선 디렉토리 구조부터 살펴보면 다음과 같다.

다이어그렘으로 확인하면 다음과 같다.

구현 코드는 다음과 같다. 간단한 코드라 설명은 생략하겠다.

public interface OAuth2UserInfo {
    String getProviderId();
    String getProvider();
    String getEmail();
    String getName();
}

대표적으로 Goolge의 구현체는 다음과 같다.

public class GoogleUserInfo implements OAuth2UserInfo{

    private Map<String, Object> attributes;

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

    @Override
    public String getProviderId() {
        return (String) attributes.get("sub");
    }

    @Override
    public String getProvider() {
        return "google";
    }

    @Override
    public String getEmail() {
        return (String) attributes.get("email");
    }

    @Override
    public String getName() {
        return (String) attributes.get("name");
    }
}

Naver는 다음과 같다.

public class NaverUserInfo implements OAuth2UserInfo{

    private Map<String, Object> attributes;

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

    @Override
    public String getProviderId() {
        return (String) attributes.get("id");
    }

    @Override
    public String getProvider() {
        return "naver";
    }

    @Override
    public String getEmail() {
        return (String) attributes.get("email");
    }

    @Override
    public String getName() {
        return (String) attributes.get("name");
    }
}

 

3-6. Enum 으로 생성 Logic 활용하기

Enum을 활용하여 생성로직까지 한번에 처리해보자! 이를 OAuthProvider 라는 이름으로 만들게 되었다.

 

이렇게 만든 이유는 각 회사마다 accesc_token을 통해 받아오는 데이터 구조가 약간씩 다르기 때문이다.

예를 들어 goole의 "sub" 에 해당되는 값이, naver 에서는 "id"에 해당되는 값이다.

 

이를 분기 처리를 통해 각 회사이름을 파악한 후, 생성하기보다는 Enum 자체에 로직을 위임하여 생성하도록 변경하였다.

public enum OAuthProvider {
    GOOGLE("google", GoogleUserInfo::new),
    FACEBOOK("facebook", FacebookUserInfo::new),
    NAVER("naver", (attributes) -> {
        Map<String, Object> response = (Map<String, Object>) attributes.get("response");
        return new NaverUserInfo(response);
    });

    private final String registrationId;
    private final Function<Map<String, Object>, OAuth2UserInfo> of;

    OAuthProvider(String registrationId, Function<Map<String, Object>, OAuth2UserInfo> of) {
        this.registrationId = registrationId;
        this.of = of;
    }

    public static OAuth2UserInfo extract(String registrationId, Map<String, Object> attributes) {
        return Arrays.stream(values())
                .filter(provider -> registrationId.equals(provider.registrationId))
                .findFirst()
                .orElseThrow(IllegalArgumentException::new)
                .of.apply(attributes);
    }
}

 

 

4. 결과 확인하기

우선 loginForm으로 이동해보면 다음과 같은 화면이 보인다.

 

로그인이 안되어 있다면 다음과 같이 로그인 요청 화면이 먼저 보인다.

각각 email, google, facebook, naver 로 정상 로그인 되며, DB에는 다음과 같이 저장되게 된다.

 

5. 참고

https://docs.spring.io/spring-security/site/docs/5.2.x/reference/html/oauth2.html

 

12. OAuth2

A JWT that is issued from an OAuth 2.0 Authorization Server will typically either have a scope or scp attribute, indicating the scopes (or authorities) it’s been granted, for example: When this is the case, Resource Server will attempt to coerce these sc

docs.spring.io

 

https://opentutorials.org/course/3405

 

WEB2 - OAuth 2.0 - 생활코딩

수업소개 사용자가 가입된 서비스의 API에 접근하기 위해서는 사용자로부터 권한을 위임 받아야 합니다. 이 때 사용자의 패스워드 없이도 권한을 위임 받을 수 있는 방법이 필요합니다. 이를 위

opentutorials.org

 

댓글