[Spring] OAuth2 인증 서버 구축하기 : JPA 연동 & 커스텀 UserDetailsService

Kai·2024년 3월 1일
0

스프링과 OAuth2

목록 보기
8/11

📌 글에서 사용한 코드 : 깃헙

☕ 개요


이번 글에서는 지난 글(1편, 2편)들에 이어서 Spring 인증 서버를 커스터마이징하는 방법에 대해서 계속 알아보도록 하겠다.
이번 글에서는 인-메모리로 동작하던 유저 저장소를 JPA와 MySQL로 구현해보도록 하겠다.

바~로 드가자 🔥


🐘 build.gradle


dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-oauth2-authorization-server'
	implementation 'org.springframework.boot:spring-boot-starter-web'
	implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'

	compileOnly 'org.projectlombok:lombok'
	annotationProcessor 'org.projectlombok:lombok'

	testImplementation 'org.springframework.boot:spring-boot-starter-test'
	runtimeOnly 'com.mysql:mysql-connector-j'
	testCompileOnly 'org.projectlombok:lombok'
	testAnnotationProcessor 'org.projectlombok:lombok'
}

위와 같이 패키지들은 설치하였고, 지난 글을 기준으로는 아래의 2개 패키지를 추가해주었다.

dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
	runtimeOnly 'com.mysql:mysql-connector-j'
}

🔨 UserDetailsService 구현


0) UserDetailsService VS UserDetailsManager

유저 관련 기능을 커스터마이징하고 싶다면, UserDetailsService의 구현체를 만들거나, UserDetailsManager의 구현체 만들어주면 된다.

UserDetailsService는 유저를 조회하는 기능만을 기본적으로 갖고 있고, UserDetailsManagerUserDetailsService를 상속받아서 유저 생성 및 업데이트, 패스워드 변경과 같은 메서드까지 포함하고 있다.

만약, 유저 관련 CRUD기능은 굳이 상속받지 않고 내가 알아서 만들겠다고 한다면, UserDetailsService의 구현체를 만들면 되고, CRUD기능도 정해진 틀을 기반으로 구현하고 싶다면, UserDetailsManager의 구현체를 만들어주면 된다.

이번 글에서는 UserDetailsService의 구현체를 만들도록 하겠다.

1) InMemoryUserDetailsManager 지우기

기존에 등록되어 있던 인-메모리 UserDetailsService 또는 UserDetailsManager는 지워준다.

2) application.yml 수정

server:
  port: 9000
spring:
  datasource:
    url: jdbc:mysql://localhost:3306/oauth
    username: root
    password: 1234
  jpa:
    open-in-view: false
    hibernate:
      ddl-auto: update
    properties:
      hibernate:
        format_sql: ture
        use_sql_comments: true
logging:
  level:
    org:
      hibernate:
        SQL: debug
        type: debug

application.yml은 이렇게 작성해주었다.
MySQL에 oauth라는 이름의 DB는 미리 만들어주자.

3) 엔티티 생성 : Authority

import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.springframework.security.core.GrantedAuthority;

@Getter
@Entity
@AllArgsConstructor
@NoArgsConstructor
public class Authority implements GrantedAuthority {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(unique = true)
    private String authority;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id")
    private User user;
}

GrantedAuthority를 상속받은 권한 엔티티를 만들어주었다.

4) 엔티티 생성 : User

import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import study.springoauth2authserver.entity.authority.Authority;

import java.util.ArrayList;
import java.util.List;


@Getter
@Entity
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class User implements UserDetails {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(unique = true)
    private String username;
    private String password;

    @JsonIgnore
    @OneToMany(mappedBy = "user")
    private List<Authority> authorities = new ArrayList<>();
    private Boolean accountNonExpired;
    private Boolean accountNonLocked;
    private Boolean credentialsNonExpired;
    private Boolean enabled;

    @Override
    public boolean isAccountNonExpired() {
        return this.accountNonExpired;
    }

    @Override
    public boolean isAccountNonLocked() {
        return this.accountNonLocked;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return this.credentialsNonExpired;
    }

    @Override
    public boolean isEnabled() {
        return this.enabled;
    }

    public static User create(String username, String password) {
        return User.builder()
                .username(username)
                .password(password)
                .accountNonExpired(true)
                .accountNonLocked(true)
                .credentialsNonExpired(true)
                .enabled(true)
                .build();
    }

    public List<SimpleGrantedAuthority> getSimpleAuthorities() {
        return this.authorities.stream().map(authority -> new SimpleGrantedAuthority(authority.getAuthority())).toList();
    }

}

위에서 GrantedAuthority를 상속받은 이유와 동일한 이유로 UserDetails를 직접 상속받아서 User엔티티를 만들어주었다.

5) UserRepository

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import study.springoauth2authserver.entity.User;

public interface UserRepository extends JpaRepository<User, Long> {

    @Query("SELECT user FROM User user JOIN FETCH user.authorities WHERE user.username=:username")
    User findByUsername(String username);

}

UserRepositoryJpaRepository를 상속받아서 구현하였고, findByUsername메서드는 Fetch join을 통해서 직접 구현하였다.

6) CustomUserDetailsService 구현

import jakarta.transaction.Transactional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Component;


@Slf4j
@Transactional
@Component
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {

    private final UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        study.springoauth2authserver.entity.user.User user = getUserByUsername(username);
        return User.builder()
                .username(user.getUsername())
                .password(user.getPassword())
                .disabled(!user.getEnabled())
                .accountExpired(!user.getAccountNonExpired())
                .accountLocked(!user.getAccountNonLocked())
                .credentialsExpired(!user.getCredentialsNonExpired())
                .authorities(user.getSimpleAuthorities())
                .build();
    }

    public study.springoauth2authserver.entity.user.User getUserByUsername(String username) throws UsernameNotFoundException {
        study.springoauth2authserver.entity.user.User user = userRepository.findByUsername(username);
        if (user == null) {
            throw new UsernameNotFoundException(username);
        }
        return user;
    }

}

UserDetailsService의 구현체를 간단히 만들어주었다. Component를 붙여서 Bean으로 등록하는 것을 잊지말자.

UserDetails와 커스텀하게 생성한 User를 조회하는 메스드를 구분해주었다. UserDetails를 return해야하는 부분에서 User를 return할 경우 파싱 에러가 생기는 경우가 많아서 구분해서 만들어주었다.


🔥 동작 확인


1) 샘플 데이터 생성

userauthority테이블에 샘플 데이터를 하나씩 만들어주었다.

2) 로그인 테스트

당연히 로그인도 잘되고, 로그인 시도를 했을 때, 쿼리도 잘 실행되는 것을 확인할 수 있다. 🫡


🙏 참고


0개의 댓글