📌 글에서 사용한 코드 : 깃헙
이번 글에서는 지난 글(1편, 2편)들에 이어서 Spring 인증 서버를 커스터마이징하는 방법에 대해서 계속 알아보도록 하겠다.
이번 글에서는 인-메모리로 동작하던 유저 저장소를 JPA와 MySQL로 구현해보도록 하겠다.
바~로 드가자 🔥
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
의 구현체를 만들거나, UserDetailsManager
의 구현체 만들어주면 된다.
UserDetailsService
는 유저를 조회하는 기능만을 기본적으로 갖고 있고, UserDetailsManager
은 UserDetailsService
를 상속받아서 유저 생성 및 업데이트, 패스워드 변경과 같은 메서드까지 포함하고 있다.
만약, 유저 관련 CRUD기능은 굳이 상속받지 않고 내가 알아서 만들겠다고 한다면, UserDetailsService
의 구현체를 만들면 되고, CRUD기능도 정해진 틀을 기반으로 구현하고 싶다면, UserDetailsManager
의 구현체를 만들어주면 된다.
이번 글에서는 UserDetailsService
의 구현체를 만들도록 하겠다.
기존에 등록되어 있던 인-메모리 UserDetailsService
또는 UserDetailsManager
는 지워준다.
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는 미리 만들어주자.
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
를 상속받은 권한 엔티티를 만들어주었다.
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
엔티티를 만들어주었다.
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);
}
UserRepository
는 JpaRepository
를 상속받아서 구현하였고, findByUsername
메서드는 Fetch join을 통해서 직접 구현하였다.
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할 경우 파싱 에러가 생기는 경우가 많아서 구분해서 만들어주었다.
user
와 authority
테이블에 샘플 데이터를 하나씩 만들어주었다.
당연히 로그인도 잘되고, 로그인 시도를 했을 때, 쿼리도 잘 실행되는 것을 확인할 수 있다. 🫡