Group Study (2022-2023)/Spring 입문

[Spring 입문] 4주차 스터디 - 스프링 시큐리티와 OAuth 2.0으로 로그인 기능 구현하기

buttery 2022. 11. 9. 22:21

구글 로그인 연동

clientId, clientSecret : 아래 링크에서 인증 정보를 발급받는다.

Google Cloud Platform

  1. 클라우드 플랫폼에 신규 프로젝트 생성하기

새 프로젝트 생성 후, API 및 서비스 > 사용자 인증 정보 > 사용자 인증 정보 만들기 > OAuth 클라이언트 ID > OAuth 동의 화면 구성

  • OAuth 동의 화면에서 앱 이름에는 구글 로그인 시 사용자에게 노출될 어플리케이션 이름을 작성한다.
  • 지원 이메일은 사용자 동의 화면에서 노출될 이메일 주소를 작성한다.
  • 구글 API 범위는 등록할 구글 서비스에서 사용할 범위 목록으로 기본값은 email, profile, openid이다

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

: API 및 서비스 > 사용자 인증정보 > 사용자 인증정보 만들기 > OAuth 클라이언트 ID 만들기

URL 주소등록: 승인된 리디렉션 URI는 서비스에서 파라미터로 인증정보를 주었을 때 인증에 성공할 시 구글에서 리다이렉트 할 URL를 뜻한다.

리디렉션 URI : https://localhost:8080/login/oauth2/code/google

스프링 부트 2 제공 디폴트 리다이렉트 URL : {도메인}/login/oauth2/code/{소셜서비스코드}

따라서 사용자가 별도로 리다이렉트 URL 지원 Controller 생성할 필요는 없다.

AWS 서버에 배포하면 localhost 외 주소를 추가해야 하지만 개발 단계에서는 보통 localhost 로 놓고 테스트

 

application-oauth 등록하기

src/ main/ resource/ … application.properties 있는 디렉토리에 application-oauth.properties 을 생성한다.

  • 클라이언트 ID, 클라이언트 보안 비밀번호 등록해준다.
  • scope의 기본 값은 openid, email, profile 이다.
spring.security.oauth2.client.registration.google.client-id={클라이언트 id} 
spring.security.oauth2.client.registration.google.client-secret={클라이언트-secret} 
spring.security.oauth2.client.registration.google.scope=profile,email

gitignore 등록하기

: 클라이언트 ID, 보안 비밀번호는 외부에 노출되면 안되는 중요 정보이기 때문에 gitignore로 관리를해준다.
깃허브 연동 시 노출되지 않도록 application-oauth.properties 파일 등록을 방지한다.

.gitignore → application-oauth.properties 추가하여 등록한다.

User 엔티티를 생성한다.

: 사용자 정보 담당할 도메인을 관리한다.

@Getter
@NoArgsConstructor
@Entity
public class User extends BaseTimeEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)  
    private Long id;

 

@Column(nullable = false)
private String name;

@Column(nullable = false)
private String email;

@Column
private String picture;

@Enumerated(EnumType.STRING)  
@Column(nullable = false)
private Role role;

@Builder
public User(String name, String email, String picture, Role role) {

    this.name = name;
    this.email = email;
    this.picture = picture;
    this.role = role;

}

public User update(String name, String picture) {
    this.name = name;
    this.picture = picture;

    return this;
}

public String getRoleKey() {
    return this.role.getKey();
}

Role Enum 클래스를 생성한다.

: 각 사용자의 권한을 관리할 Enum 클래스

@Getter
@RequiredArgsConstructor
public enum Role {

GUEST("ROLE_GUEST", "손님"),
USER("ROLE_USER", "일반 사용자");

private final String key;
private final String title;

}

UserRepository를 생성한다.

: User 관련 CRUD 를 담당한다.

public interface UserRepository extends JpaRepository<User, Long> {
    Optional<User> findByEmail(String email);
}

어노테이션 기반으로 개선하기

스프링에서 프로그램에 관한 데이터를 제공하거나, 코드에 정보를 추가할 때 사용하는 것을 어노테이션이라 한다. 이미 만들어진 어노테이션 뿐만 아니라 직접 커스텀해서 만들 수 있는데, 이를 커스텀 어노테이션이라 한다.

IndexController 부분에서 세션값이 필요하면 그때마다 아래 세션에서 값을 가져오는 코드를 반복하게 된다. 같은 코드가 계속 반복되므로, 커스컴 어노테이션을 만들어 개선해보자.

SessionUser user = (SessionUser) httpSession.getAttribute("user");

→ 메소드 인자로 세션값을 바로 받을 수 있도록 변경해보자.

  1. config/auth/@LoginUser
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface LoginUser {
}
  • 어노테이션이 생성될 수 있는 위치를 지정한다
  • PARAMETER로 지정하였으므로, 메소드의 파라미터로 선언된 객체에서만 사용 가능
  • TYPE : 클래스 선언문에 사용 가능
  • @interface
    • 해당 파일을 어노테이션 클래스로 지정
    • LoginUser라는 이름의 어노테이션이 생성되었다
  • @Retention
    • 어노테이션이 언제까지 유효할지 결정하는 어노테이션

config/auth/LoginUserArgumentResolver : HandlerMethodArgumentResolver 인터페이스 구현

@RequiredArgsConstructor
@Component
public class LoginUserArgumentResolver implements HandlerMethodArgumentResolver {

    private final HttpSession httpSession;

    @Override
    public boolean supportsParameter(MethodParameter parameter) {

        boolean isLoginUserAnnotation = parameter.getParameterAnnotation(LoginUser.class) != null;
        boolean isUserClass = SessionUser.class.equals(parameter.getParameterType());

        return isLoginUserAnnotation && isUserClass;
    }

    @Override
    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest,
                                  WebDataBinderFactory binderFactory) throws Exception {
        return httpSession.getAttribute("user");
    }
}
  • HandlerMethodArgumentResolver
    • 조건에 맞는 경우, 메소드가 있다면 HandlerMethodArgumentResolver 구현체가 지정한 값으로 해당 메소드의 파라미터로 넘길 수 있다.
  • supportsParameter()
    • 컨트롤러 메서드의 특정 파라미터를 지원하는지 판단한다
    • @LoginUser 어노테이션이 붙어있고, 파라미터 클래스 타입이 SessionUser.class인 경우 true를 반환한다.
  • resolveArgument()
    • 파라미터에 전달할 객체를 생성
    • 여기서는 세션에서 객체를 가져온다.

config.WebConfig : 생성된 LoginUserArgumentResolver가 스프링에서 인식되게 설장 추가

@RequiredArgsConstructor
@Configuration
public class WebConfig implements WebMvcConfigurer {

    private final LoginUserArgumentResolver loginUserArgumentResolver;

    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {

        resolvers.add(loginUserArgumentResolver);
    }
}
  • HandlerMethodArgumentResolver는 항상 WebMvcConfigurer의 addArgumentResolvers()로 추가해준다.

IndexController 코드 수정

@GetMapping("/")
public String index(Model model, @LoginUser SessionUser user){
    model.addAttribute("posts", postsService.findAllDesc()); // 메서드 결과로 가져온 posts를 index.mustache에 전달한다

    if (user != null){
        model.addAttribute("userName", user.getName());
    }
    return "index";
}
  • @LoginUser SessionUser user
    • 기존에 (SessionUser) httpSession.getAttribute("user");로 가져오던 세션 정보값이 개선
    • 어느 컨트롤러든지 @LoginUser만 사용하면 세션 정보를 가져올 수 있다.

Spring Security란?

Spring 기반의 애플리케이션의 보안을 담당하는 스프링 하위 프레임워크이다.

인증과 권한에 대한 부분을 Filter 흐름에 따라 처리하며, 기본적으로 인증(Authentication) 절차를 거친 후, 인증이 성공하면 인가(Authorization) 절차를 진행한다. (인증 → 인가)

인가 과정에서 해당 리소스에 대한 접근 권한이 있는지 확인한다. 이런 인증과 인가를 위해 Principal을 아이디로, Credential을 비밀번호로 사용하는 Credential 기반의 인증방식을 사용한다.

  • Principals : 접근 주체. 보호받는 resource에 접근하는 대상
  • Credentials : 비밀번호, resource에 접근하는 대상의 비밀번호

✨ Spring Security 주요 모듈

  • SecurityContextHolder
    • 보안 주체의 세부 정보를 포함하여 응용프로그램의 현재 보안 컨텍스트에 대한 세부정보가 저장된다.
  • SecurityContext
    • Authentication을 보관하는 역할을 하며, SecurityContext를 통해 Authentication 객체를 꺼내올 수 있다.
  • Authentication
    • 현재 접근하는 주체의 정보와 권한을 담는 인터페이스이다.
    • Authentication 객체는 Security Context에 저장된다.
    • SecurityContextHolder를 통해 SecurityContext에 접근하고, SecurityContext를 통해 Authentication에 접근할 수 있다.
  • UsernamePasswordAuthenticationToken
    • Authentication을 implements한 AbstractAuthenticationToken의 하위 클래스
    • user의 ID가 Principal 역할을 하고, password가 Credential 역할을 한다.
    • 인증 완료 전의 객체를 생성하고, 인증 완료 후의 객체를 생성하는 역할을 한다.
  • AuthenticationProvider
    • 실제 인증에 대한 부분을 처리한다.
    • Authentication 객체를 받아서 인증이 완료된 객체를 반환하는 역할을 한다.
  • AuthenticationManager
    • 인증은 Authentication Manager를 통해서 처리하는데, 실질적으로는 Authentication Manager에 등록된 AuthenticationProvider에 의해 처리된다.
    • 인증이 성공하면 생성자를 통해 인증이 성공한 객체를 생성하여 Security Context에 저장한다.
    • 인증상태 유지를 위해 세션에 보관하며, 인증이 실패한 경우 AuthenticationException을 발생시킨다.
  • UserDetails
    • 인증에 성공하여 생성된 UserDetails 객체는 Authentication객체를 구현한 UsernamePasswordAuthenticationToken을 생성하기 위해 사용된다.
    • UserDetails 인터페이스에는 정보를 반환하는 메서드들을 갖고 있다.
  • UserDetailsService
    • UserDetailsService 인터페이스는 UserDetails객체를 반환하는 단 하나의 메소드, loadUserByUsername을 갖고 있다.
    • 일반적으로 이를 구현한 클래스의 내부에 UserRepository를 주입받아 db와 연결하여 처리해준다.
  • Password Encoding
    • 패스워드 암호화에 사용될 PasswordEncoder 구현체를 지정할 수 있다.
  • GrantedAuthority
    • 현재 principal(사용자)이 가지고 있는 권한을 뜻한다.
    • ROLE_* 의 형태로 사용하며, UserDetailsService에 의해 불러올 수 있다.
    • 특정 자원에 대한 권한이 있는지를 검사하여 접근 허용 여부를 결정한다.

네이버 로그인

네이버 오픈 API (https://developers.naver.com/apps/#/register) 이용하여 로그인 구현 가능

  1. 애플리케이션 등록
  • Callback URL: 구글에서 등록한 redirection URL과 같은 역할

등록 완료 후 위와 같이 ClientID, ClientSecret이 생성됨

  1. application-oauth.properties에 생성된 키값 등록
# registration
spring.security.oauth2.client.registration.naver.client-id="클라이언트 ID 값"
spring.security.oauth2.client.registration.naver.client-secret="클라이언트 비밀 값"
spring.security.oauth2.client.registration.naver.redirect-uri={baseUrl}/{action}/oauth2/code/{registrationId}
spring.security.oauth2.client.registration.naver.authorization-grant-type=authorization_code
spring.security.oauth2.client.registration.naver.scope=name,email,profile_image
spring.security.oauth2.client.registration.naver.client-name=Naver

# provider
spring.security.oauth2.client.provider.naver.authorization-uri=https://nid.naver.com/oauth2.0/authorize
spring.security.oauth2.client.provider.naver.token-uri=https://nid.naver.com/oauth2.0/token
spring.security.oauth2.client.provider.naver.user-info-uri=https://openapi.naver.com/v1/nid/me
spring.security.oauth2.client.provider.naver.user_name_attribute=response

네이버는 스프링 시큐리티를 공식 지원하지 않음 → Common OAuth2Provider에서 해주던 값들도 모두 입력해야 함

  • user_name_attribute=response이때 스프링 시큐리티에서는 하위 필드를 명시 불가 → response를 user_name으로 지정 후, response의 id를 user_name으로 지정하는 방식으로 해결!
  • 네이버의 회원 조회 시 반환되는 JSON 형태에는 최상위 필드가 3개 존재 (resultCode, message, response)
  1. 스프링 시큐리티 설정 등록

구글 로그인 등록 시 사용한 OAuthAttributes에 네이버 판단 코드, 네이버 생성자 추가

// OAuthAttributes.java 내 코드 추가

public static OAuthAttributes of(String registrationId, String userNameAttributeName, Map<String, Object> attributes) {
        **if ("naver".equals(registrationId)) {
            return ofNaver("id", attributes);
        }**
        return ofGoogle(userNameAttributeName, attributes);
    }

**private static OAuthAttributes ofNaver(String userNameAttributeName, Map<String, Object> attributes) {
        Map<String, Object> response = (Map<String, Object>) attributes.get("response");

        return OAuthAttributes.builder()
                .name((String) response.get("name"))
                .email((String) response.get("email"))
                .picture((String) response.get("profile_image"))
                .attributes(response)
                .nameAttributeKey(userNameAttributeName)
                .build();
    }**
  1. 네이버 로그인 버튼 추가

index.mustache에 작성

<a href="/oauth2/authorization/naver" class="btn btn-secondary active" role="button">Naver Login</a>
  • /oauth2/authorization/naver
  • 네이버 로그인 URL: redirect_uri_template (application-oauth.properties에 등록) 값에 맞춰 자동 등록됨