본문 바로가기
FrameWork/Spring

스프링 시큐리티/ OAuth2.0 로그인 기능 구현

by 태윤2 2021. 8. 21.

스프링 시큐리티(Spring Security)는 막강한 인증(Authentication)과 인가(Autorization(혹은 권한부여)) 기능을 가진 프레임워크 이다. 스프링 기반의 애플리케이션에서의 보안을 위한 표준이라고 보면 된다.

 

application-oauth.yml 파일 생성후 설정

spring:
  security:
    oauth2:
      client:
        registration:
          google:
            client-id: 클라이언트 ID
            client-secret: 클라이언트 보안 비밀
            scope: profile,email

 

application.yml 파일

추가해야지 oauth 설정을 사용할 수 있음

.gitignore

보안을위해 추가

 

user 패키지에 User Entity 생성

@Getter
@NoArgsConstructor
@Entity
public class User {

    @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.getRoleKey();
    }
}
  1. @Enumerated(EnumType.STRING)
    • JPA로 데이터베이스를 저장할 때 Enum 값을 어떤 형태로 저장할지를 결정합니다.
    • 기본적으로는 int로 된 숫자가 저장됨
    • 숫자로 저장되면 데이터베이스로 확일할 때 그 값이 무슨 코드를 의미하는지 알 수가 없음
    • 그래서 문자열(EnumType.STRING)로 저장될 수 있도록 선언
@Getter
@RequiredArgsConstructor
public enum Role {

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

    private final String key;
    private final String title;
}
  • 스프링 시큐리티에서는 권한 코드 앞에 항상 ROLE_이 앞에 있어야만 함

UserRepository 생성

public interface UserRepository extends JpaRepository<User, Long> {

    Optional<User> findByEmail(String email);
}

스프링 시큐리티 설정

  • build.gradle에 스프링 시큐리티 관련 의존성 추가

  1. spring-boot-starter-oauth2-client
    • 소셜 로그인 등 클라이언트 입장에서 소셜 기능 구현 시 필요한 의존성
    • spring-security-oauth2-client와 spring-security-oauth2-jose를 기본으로 관리해줌

 

config.auth 패키지 생성(시큐리티 관련 클래스는 모두 이곳에 담음)

@RequiredArgsConstructor
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    private final CustomOAuth2userService customOAuth2userService;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .csrf().disable()
                .headers().frameOptions().disable()
            .and()
                .authorizeRequests()
                .antMatchers("/", "/css/**", "/images/**", "/js/**", "/h2-console/**").permitAll()
                .antMatchers("/api/v1/**").hasRole(Role.USER.name())
                .anyRequest().authenticated()
            .and()
                .logout()
                .logoutSuccessUrl("/")
            .and()
                .oauth2Login()
                .userInfoEndpoint()
                .userService(customOAuth2userService);
    }
}
  1. @EnableWebSecurity
    • Spring Security 설정들을 활성화시켜 줌
  2. csrf().disable().headers().frameOptions().disable()
    • h2-console 화면을 사용하기 위해 해당 옵션들을 disable 함
  3. authorizeRequests
    • URL별 권한 관리를 설정하는 옵션의 시작점
    • authorizeRequests가 선언되어야만 antMatchers 옵션을 사용할 수 있음
  4. antMatchers
    • 권한 관리 대상을 지정하는 옵션
    • URL, HTTP 메소드별로 관리가 가능
    • "/"등 지정된 URL들은 permitAll() 옵션을 통해 전체 열람 권한을 줌
    • "/api/v1/**" 주소를 가진 API는 USER 권한을 가진 사람만 가능하도록 함
  5. anyRequest
    • 설정된 값들 이외 나머지 URL들을 나타냄
    • 여기서는 authenticated()을 추가하여 나머지 URL들은 모두 인증된 사용자들에게만 허용하게 함
    • 인증된 사용자 즉, 로그인한 사용자들을 이야기함
  6. logout().logoutSuccessUrl("/")
    • 로그아웃 기능에 대한 여러 설정의 진입점
    • 로그아웃 성공 시 /주소로 이동함
  7. oauth2Login
    • OAuth 2 로그인 기능에 대한 여러 설정의 진입점
  8. userInfoEndpoint
    • OAuth 2 로그인 성공 이후 사용자 정보를 가져올 때의 설정들을 담당
  9. userService
    • 소셜 로그인 성공 시 후속 조치를 진행할 UserService 인터페이스의 구현체를 등록함
    • 리소스 서버(즉, 소셜 서비스들)에서 사용자 정보를 가져온 상태에서 추가로 진행하고자 하는 기능을 명시할 수 있음

CustomOAuth2UserService 클래스 생성

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

    private final UserRepository userRepository;
    private final HttpSession httpSession;

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        OAuth2UserService<OAuth2UserRequest, OAuth2User> 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());

        User user = saveOrUpdate(attributes);

        httpSession.setAttribute("user", new SessionUser(user));

        return new DefaultOAuth2User(Collections.singleton(new SimpleGrantedAuthority(user.getRoleKey())), attributes.getAttributes(), attributes.getNameAttributeKey());
    }

    private User saveOrUpdate(OAuthAttributes attributes) {
        User user = userRepository.findByEmail(attributes.getEmail())
                .map(entity -> entity.update(attributes.getName(), attributes.getPicture()))
                .orElse(attributes.toEntity());

        return userRepository.save(user);

    }
}
  1. registrationId
    • 현재 로그인 징행 중인 서비스를 구분하는 코드
    • 지금은 구글만 사용하는 불필요한 값이지만, 이후 네이버 로그인 연동 시에 네이버 로그인인지, 구글 로그인인지 구분하기 위해 사용
  2. userNameAttributeName
    • OAuth2 로그인 진행 시 키가되는 필드값. Primary Key와 같은 의미
    • 구글의 경우 기본적으로 코드를 지원하지만, 네이버 카카오 등은 기본 지원하지 않음 구글의 기본 코드는 "sub"
    • 이후 네이버 로그인, 구글 로그인을 동시 지원할 때 사용
  3. OAuthAttributes
    • OAuth2userService를 통해 가져온 OAuth2User의 attribute를 담을 클래스
    • 이후 네이버 등 다른 소셜 로그인도 이 클래스를 사용함
  4. SessionUser
    • 세션에 사용자 정보를 저장하기 위한 dto 클래스
    • 세션에 저장하기위해 User 클래스에 직렬화를 구현해야하는데 Entity 클래스인 user 클래스에 직렬화를 구현하기보다 직렬화가 구현된 Dto 클래스를 추가하는 것이 운영 및 유지보수 때 많은 도움이 됨

OauthAttributes 클래스

@Getter
public class OAuthAttributes {

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

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

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

    private static OAuthAttributes ofGoogle(String userNameAttributeName, Map<String, Object> attributes) {
        return OAuthAttributes.builder()
                .name((String) attributes.get("name"))
                .email((String) attributes.get("email"))
                .picture((String) attributes.get("picture"))
                .attributes(attributes)
                .nameAttributeKey(userNameAttributeName)
                .build();
    }

    public User toEntity() {
        return User.builder()
                .name(name)
                .email(email)
                .picture(picture)
                .role(Role.GUEST)
                .build();
    }
}
  1. of()
    • Oauth2user에서 반환하는 사용자 정보는 Map이기 때문에 값 하나하나를 변환해야 함
  2. toEntity()
    • User 엔티티를 생성
    • OAuthAttributes에서 엔티티를 생성하는 시점은 처음 가입할 때
    • 가입할 때의 기본 권한을 GUEST로 주기 위해 role 빌더값에는 Role.GUEST를 사용

SessionUser클래스 생성

@Getter
public class SessionUser implements Serializable {
    private String name;
    private String email;
    private String picture;

    public SessionUser(User user) {
        this.name = user.getName();
        this.email = user.getEmail();
        this.picture = user.getPicture();
    }
}
  • SessionUser에는 인증된 사용자 정보만 필요함 그 외에 필요한 정보들은 없으니 name, eail, picture만 필드로 선언

 

Login 버튼 추가

  1. {{#userName}}
    • 머스테치는 다른 언어와 같은 if문을 제공하지 않음
    • true/false 여부만 판단
    • 그래서 머스테치는 항상 최종값을 넘겨줘야 함
    • 여기서는 userName으로 판별
  2. a href="/logout"
    • 스프링 시큐리티에서 기본적으로 제공하는 로그아웃 URL
    • 즉, 개발자가 별도로 저 URL에 해당하는 컨트롤러를 만들 필요가 없음
    • SecurityConfig 클래스에서 URL을 변경할 순 있지만 기본 URL을 사용해도 충분
  3. {{^userName}}
    • 머스테치에서 해당 값이 존재하지 않는 경우에는 ^를 사용
    • 여기서는 userName이 없다면 로그인 버튼을 노출
  4. a href="/oauth2/authorization/google"
    • 스프링 시큐리티에서 기본적으로 제공하는 로그인 URL
    • 로그아웃 URL과 마찬가지로 개발자가 별도의 컨트롤러를 생성할 필요가 없음

Controller에서 userName을 model에 저장

  1. (SessionUser)httpSession.getAttribute("user")
    • 앞에 작성된 CustomOAuth2UserService에서 로그인 성공 시 세션에 SessionUser를 저장하도록 구현
    • 로그인 성공 시 httpSession.getAttribute("user")에서 값을 가져올 수 있음
  2. if (user != null)
    • 세션에 저장된 값이 있을 때만 model에 userName으로 등록
    • 세션에 저장된 값이 없으면 model엔 아무런 값이 없는 상태이니 로그인 버튼이 보이게 됨

 

 

  • 참고자료
    • 스트링부트와 AWS로 혼자 구현하는 웹 서비스

'FrameWork > Spring' 카테고리의 다른 글

Session 저장소로 데이터베이스 사용하기  (0) 2021.08.21
로그인 기능 개선  (0) 2021.08.21
게시글 등록 화면  (0) 2021.08.21
머스테치  (0) 2021.08.21
JPA Auditing으로 생성시간/수정시간 자동화하기  (0) 2021.08.20