[Springboot] OAuth2 와 JWT (3) database 연결
이전 글 - SNS 연동 편 에서 이어집니다.
구현
springboot를 이용해 OAuth2.0 과 JWT를 이용해 로그인을 구현합니다.
클라이언트는 별도 구성 없이 springboot에서 thymeleaf를 이용해 구현합니다
환경
환경 | 버전 |
---|---|
java | 11 |
springboot | 2.7.0 |
gradle | 7.4.1 |
Step 2. 회원가입 (Database 연결)
1. 인메모리 데이터베이스 h2 연결하기
DB 스키마가 계속 변경될 수 있어 여기서는 h2로 진행합니다.
의존성 추가
- build.gradle
dependencies {
...
// DATABASE
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
runtimeOnly 'com.h2database:h2'
...
}
datasource 설정
spring.datasource.url 을 지정하지 않으면 매 실행시마다 jdbc url이 랜덤 하게 바뀝니다. test로 고정하겠습니다.
- application.yml
spring:
...
h2:
console:
enabled: true
datasource:
driver-class-name: org.h2.Driver
url: jdbc:h2:mem:test
...
h2-console 접근을 위한 security 설정
h2-console 접근 편의성을 위한 보안 예외를 처리합시다.
http와 web configure이 모두 필요합니다. csrf 쪽도 설정해주어야 합니다.
- SecurityConfiguration.java
@EnableWebSecurity
public class SecurityConfiguration {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
// @formatter:off
http
.authorizeRequests(a -> a
.antMatchers("/", "/error", "/webjars/**", "/h2-console/**").permitAll()
.anyRequest().authenticated()
)
.exceptionHandling(e -> e
.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))
)
.logout(l -> l
.logoutSuccessUrl("/").permitAll()
)
.csrf(c -> c
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
.ignoringAntMatchers("/h2-console/**").disable()
)
.httpBasic(withDefaults())
.oauth2Login();
// @formatter:on
return http.build();
}
@Bean
public WebSecurityCustomizer webSecurityCustomizer() {
return web -> web.ignoring().antMatchers("/h2-console/**");
}
}
여기까지 잘 되었는지 테스트 : localhost:8080/h2-console 접속. 양식 입력 후 Test Connection, connect 정상 수행
- URL : jdbc:h2:mem:test (application.yml > spring.datasource.url 에 작성한 내용)
- User Name : sa (default)
- Password : 값 없음 (default)
2. OAuthUserService 추가
OAuthUserService 등록
OAuth 로그인에 성공했을 때 동작할 서비스를 등록하겠습니다.
oauth/CustomOAuth2UserService.java
기존과 같게 동작하도록 DefaultOAuth2UserService 를 사용해줍니다.
기본 동작을 DefaultOAuth2UserService가 대신 해주어 보통 변수명을 delegate(대리자) 라고 짓습니다.
@Service
public class CustomOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
OAuth2UserService<OAuth2UserRequest, OAuth2User> delegate = new DefaultOAuth2UserService();
OAuth2User oAuth2User = delegate.loadUser(userRequest);
return oAuth2User;
}
}
작성한 서비스를 security에 붙여줍시다.
config/SecurityConfiguration.java
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfiguration {
private final CustomOAuth2UserService customOAuth2UserService;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
// @formatter:off
http
...
.oauth2Login(o -> o
.userInfoEndpoint()
.userService(customOAuth2UserService));
// @formatter:on
return http.build();
}
여기까지 잘 되었는지 테스트 : 로그인, 로그아웃, h2-console이 이전과 똑같이 잘 동작합니다.
extract attributes
DefautlOAuth2UserService.loadUser 의 github 결과를 찍어보면 아래와 같습니다.
Granted Authorities: [[ROLE_USER, SCOPE_read:user]],
User Attributes: [
{login=Cherrue,
avatar_url=https://avatars.githubusercontent.com/u/22141516?v=4,
name=TaeHyeong Lee,
[email=th885172@gmail.com](mailto:email=th885172@gmail.com),
...
}]
Granted Authorites 는 우리 서비스의 권한이 아닌, 연결시킨 서비스에서의 권한입니다.
attributes는 Map 형태인데, 제공 서비스마다 필드 명이 다릅니다.
서비스마다 적절하게 데이터를 추출해주는 객체를 작성합니다.
oauth/UserDto.java
객체 사이에서 데이터를 들고 다닐 객체입니다. 관리할 데이터 중 최소한의 데이터만 갖습니다.
@Getter
public class UserDto {
private final String registrationId;
private final String oAuthId;
private final String email;
private final String name;
private final String image;
@Builder
public UserDto(String registrationId, String oAuthId, String email, String name, String image) {
this.registrationId = registrationId;
this.oAuthId = oAuthId;
this.email = email;
this.name = name;
this.image = image;
}
public Map<String, Object> toMap() {
Map<String, Object> map = new HashMap<>();
map.put("registrationId", registrationId);
map.put("id", oAuthId);
map.put("name", name);
map.put("email", email);
map.put("image", image);
return map;
}
}
oauth/OAuthAttributes.java
서비스(registrationId)에 따라 적절하게 데이터를 추출하는 enum 입니다.
public enum OAuthAttributes {
GITHUB("github", (attributes) -> UserDto.builder()
.registrationId((String) attributes.get("registrationId"))
.oAuthId(String.valueOf(attributes.get("id"))) // Integer
.name((String) attributes.get("name"))
.email((String) attributes.get("email"))
.image((String) attributes.get("avatar_url"))
.build());
private final String registrationId;
private final Function<Map<String, Object>, UserDto> of;
OAuthAttributes(String registrationId, Function<Map<String, Object>, UserDto> of) {
this.registrationId = registrationId;
this.of = of;
}
public static UserDto extract(String registrationId, Map<String, Object> attributes) {
return Arrays.stream(values()) // values 는 enum 의 values 이다. 이 enum 개체를 반복문 돌린다고 보면 된다.
.filter(provider -> registrationId.equals(provider.registrationId)) // 이 enum 객체가 반복문을 돌고 있으니 provider 는 OAuthAttributes 이다.
.findFirst() // 일치하는 registrationId를 찾는다.
.orElseThrow(IllegalArgumentException::new) // 없으면 에러를 발생시킨다.
.of.apply(attributes); // 이 of 가 UserDto.builder 로 값을 매핑해주는 부분이다.
}
}
oauth/entity/Role.java
내 서비스의 권한을 의미합니다. 현재는 일반 user만 있습니다.
spring security의 규칙 상 항상 “ROLE_” prefix 가 붙어야 합니다.
@Getter
@RequiredArgsConstructor
public enum Role {
ROLE_USER("ROLE_USER");
private final String key;
}
oauth/CustomOAuth2UserService.java
데이터 추출을 서비스에 추가
@Service
@Slf4j
public class CustomOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
// 기본 동작
OAuth2UserService<OAuth2UserRequest, OAuth2User> delegate = new DefaultOAuth2UserService();
OAuth2User oAuth2User = delegate.loadUser(userRequest);
// 정보 제공 서비스의 id
String registrationId = userRequest.getClientRegistration().getRegistrationId();
// 제공 서비스 별 키 값으로 사용되는 attribute 의 이름
String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName();
// 수정할 수 있게 새로운 map 에 담음
Map<String, Object> attributes = new HashMap<>(oAuth2User.getAttributes());
attributes.put("registrationId", registrationId); // 계속 필요한 정보라 추가
// 회원 가입에 사용할 데이터 추출
UserDto userDto = OAuthAttributes.extract(registrationId, attributes);
Map<String, Object> memberAttributes = userDto.toMap();
log.info(userDto.toString());
log.info(memberAttributes.toString());
// 우리 서비스에 맞는 권한 부여
return new DefaultOAuth2User(
Collections.singleton(new SimpleGrantedAuthority(Role.ROLE_USER.getKey())),
memberAttributes, // 필요한 데이터만 전달
userNameAttributeName
);
}
}
여기까지 잘 되었는지 테스트 : 로그인 시 콘솔에 로그가 정상적으로 남는다.
2022-06-06 20:50:26.729 INFO 9980 --- [nio-8080-exec-8] m.c.p.oauth.CustomOAuth2UserService : {image=https://avatars.githubusercontent.com/u/22141516?v=4, registrationId=github, name=TaeHyeong Lee, id=22141516, email=th885172@gmail.com}
2022-06-06 20:50:28.233 INFO 9980 --- [nio-8080-exec-1] m.c.p.PrototypeOauthJwtApplication : Name: [22141516], Granted Authorities: [[ROLE_USER]], User Attributes: [{image=https://avatars.githubusercontent.com/u/22141516?v=4, registrationId=github, name=TaeHyeong Lee, id=22141516, email=th885172@gmail.com}]
3. 사용자 정보 DB에 저장
entity, repository 작성
/src/main/resources/application.yml
open-in-view : view가 그려질 때 까지 트랜잭션 유지
ddl-auto : DDL 발생 시 쿼리 실행 여부. update로 하면 entity 구조를 보고 알아서 업데이트 칩니다. 초기 개발 중에는 update, 운영 시에는 꺼버립니다.
show-sql : 실행된 쿼리문들을 로그에 찍습니다.
spring:
jpa:
open-in-view: true
hibernate:
ddl-auto: update
show-sql: true
...
oauth/entity/Member.java
@Getter
@NoArgsConstructor
@Entity
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private Integer id;
private String registrationId;
private String oAuthId;
private String email;
private String name;
private String image;
@Enumerated(EnumType.STRING)
private Role role;
@Builder
public Member(String registrationId, String oAuthId, String email, String name, String image, Role role) {
this.registrationId = registrationId;
this.oAuthId = oAuthId;
this.email = email;
this.name = name;
this.image = image;
this.role = role;
}
public Member update(String name, String email, String image) {
this.name = name;
this.email = email;
this.image = image;
return this;
}
}
oauth/repository/MemberRepository.java
원래는 @Query 어노테이션 없이 JPA 가 알아서 쿼리를 만들어줘야 하지만,
o_auth_id 와 같이 한 글자로 시작하는 경우 jpa 가 알아서 찾아가지 못합니다.
Query 어노테이션을 붙여 구현해줍시다.
@Repository
public interface MemberRepository extends JpaRepository<Member, Integer> {
@Query(nativeQuery = true, value = "SELECT * FROM member m WHERE m.registration_id = ?1 AND m.o_auth_id = ?2")
Optional<Member> findByRegistrationIdAndOAuthId(String registrationId, String oAuthId);
}
여기까지 잘 되었는지 테스트 : 실행 시 로그 확인
Hibernate: create table member (id integer not null, email varchar(255), image varchar(255), name varchar(255), o_auth_id varchar(255), registration_id varchar(255), role varchar(255), primary key (id))
Hibernate: create sequence hibernate_sequence start with 1 increment by 1
oauth/dto/UserDto.java
Dto를 entity로 바꾸어주는 함수를 작성합니다.
모든 사용자는 기본적으로 user 권한으로 시작하기 때문에, 유저 권한을 기본으로 넣어줍니다.
@Getter
public class UserDto {
...
public Member toMember() {
return Member.builder()
.registrationId(registrationId)
.oAuthId(oAuthId)
.name(name)
.email(email)
.image(image)
.role(Role.ROLE_USER)
.build();
}
}
oauth/CustomOAuth2UserService
@RequiredArgsConstructor
@Service
@Slf4j
public class CustomOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
private final MemberRepository memberRepository;
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
...
// 우리 DB에 저장 == 회원가입
Member member = saveOrUpdate(userDto);
// 우리 서비스에 맞는 권한 부여
return new DefaultOAuth2User(
Collections.singleton(new SimpleGrantedAuthority(Role.ROLE_USER.getKey())),
memberAttributes, // 필요한 데이터만 전달
userNameAttributeName
);
}
private Member saveOrUpdate(UserDto userDto) {
// OAuth 제공 서비스 측 데이터가 변경될 수 있기 때문에 name, email, picture 업데이트
// registrationId, oAuthId 는 기본적으로 변경이 없는 데이터입니다.
Member member = memberRepository.findByRegistrationIdAndOAuthId(userDto.getRegistrationId(), userDto.getOAuthId())
.map(m -> m.update(userDto.getName(), userDto.getEmail(), userDto.getImage()))
.orElse(userDto.toMember());
return memberRepository.save(member);
}
}
여기까지 잘 되었는지 테스트 : 로그인 정상 동작. 로그인 성공 후 h2-console에서 저장된 데이터 확인
이번 장에서 저장한 회원 정보를 이용해 다음 편에서는 JWT 세션 유지를 구현하겠습니다.
댓글남기기