
기록 이유
맨 처음 로그인 구현했을 때 어떻게 구현했는지 따로 기록을 남겨뒀는데, 블로그로 옮기기 위해 작성하게 되었다. 기록 시작 🙂
어떻게 구현했나 ?
나는 Spring Security 를 활용해 Form Login 과 OAuth2 로그인을 진행했다. 이전 토이 프로젝트에서는 Spring Security 를 사용해보지 않아서 이번 프로젝트에서는 꼭 적용해보겠다 다짐했고 실천을 위해 어느정도 학습 후 커스텀하게 인증 로직을 구현할 수 있었다. 먼저 Form Login 구현부터 차근차근 살펴보자.
0-1) Login 관련 디렉토리 구조

0-2) UserDetails , OAuth2User 타입 구현체
Form Login 과 OAuth2 Login 을 통합 관리하기 위해 UserDetails, OAuth2User 인터페이스를 다중 상속했다. 생성자가 2갠데, Map<String, Object> attributes 변수가 포함되있는 생성자는 OAuth2 Login 전용, 그 아래 생성자는 Form Login 전용 생성자이다.
package com.market.carrot.login.config.customAuthentication.common;
import ...
@Getter
public class MemberContext implements UserDetails, OAuth2User {
private final Member member;
private final Map<String, Object> attributes;
public MemberContext(Member member, Map<String, Object> attributes) {
this.member = member;
this.attributes = attributes;
}
public MemberContext(Member member) {
this(member, null);
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
List<GrantedAuthority> roles = new ArrayList<>();
roles.add(new SimpleGrantedAuthority(member.getRole().getKey()));
return roles;
}
@Override
public String getPassword() {
return member.getPassword();
}
@Override
public String getUsername() {
return member.getUsername();
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
/**
* getAttributes(), getName() 은 OAuth2User 추상 메서드
*/
@Override
public Map<String, Object> getAttributes() {
return attributes;
}
@Override
public String getName() {
return null;
}
}
1. Form Login Authentication 플로우 구성
[만약 해당 포스팅을 보는 분이 계시다면 이전 Spring Security 포스팅을 참조해주세요 ㅎ_ㅎ]
https://jeonboard.tistory.com/151
[Spring Security] Authentication 인증 처리 과정 알아보기
작성 이유 지금까지 Spring Security 프레임워크를 사용하지 않고 로그인, 인증, 인가를 구현했었다. 상당히 어려웠었는데, Spring Security 를 학습하면서 Spring Security 가 얼마나 잘 짜여진 보안 / 인증관
jeonboard.tistory.com
기본적인 Member Entity 는 건너뛰고 Form Login 의 Authentication 플로우 구성부터 살펴보자. 커스텀 할 인터페이스는AuthenticationProvider 와 Authentication 내부를 채우고 다시 Provider 로 Authentication 을 반환하는 UserDetailsService 두 인터페이스이다.
1-1) CustomProvider 생성
Provider 는 Manager 를 대신해 실제 인증 로직을 처리한다. CustomProvider 를 Bean 객체로 등록하면, Authentication Manager 에서 자동으로 해당 Provider 를 호출하게 되니 @Component 와 같은 어노테이션 등으로 꼭 Bean 객체로 등록했다.
- Provider 는 loadUserByUsername(username); 메서드를 통해 UseDetails 구현체에 유저 정보 + 권한 정보를 셋팅한다.
- 이후 알맞은 Authentication Token 객체에 UserDetails 구현체에 담아놓은 유저 정보 + 권한 정보를 담아 Manager 로 반환
- Form Login 방식에서 사용되는 Authentication 구현체인 UsernamePasswordAuthenticationToken 객체 생성자를 통해 셋팅, 반환하도록 구현.
package com.market.carrot.login.config.customAuthentication.form;
import ...
@RequiredArgsConstructor
@Component
public class CustomProvider implements AuthenticationProvider {
private final UserDetailsService userDetailsService;
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
String username = authentication.getName();
String password = (String) authentication.getCredentials();
MemberContext memberContext = (MemberContext) userDetailsService.loadUserByUsername(username);
return new UsernamePasswordAuthenticationToken(
memberContext, null, memberContext.getAuthorities());
}
@Override
public boolean supports(Class<?> authentication) {
return UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication);
}
}
나는 패스워드 체크 예외 처리 로직을 추가했다.

1-2) CustomUserDetailsService 생성
CustomProvider 의 loadUserByUsername() 메서드 호출 후 UserDetails 타입 구현체를 반환을 담당하는 클래스이다. 이 클래스 또한 @Bean 으로 등록했고 간단한 예외 처리를 추가해서 구현했다.
Security FilterChain() 에서 CustomUserDetailsService 를 꼭 등록해줘야 한다.
... 중략
http.oauth2Login()
.loginPage("/loginForm")
.userInfoEndpoint()
.userService(oAuth2UserDetailsService); // 이 부분 !!
... 중략
@RequiredArgsConstructor
@Service
public class CustomUserDetailsService implements UserDetailsService {
private final LoginRepository loginRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
/**
* Provider 로 부터 호출받아 Authentication 내부를 채우고 다시 Provider 로 반환한다.
*/
Member findMember = loginRepository.findByUsername(username)
.orElseThrow(() -> new CustomException(ExceptionMessage.NOT_FOUND_MEMBER, HttpStatus.BAD_REQUEST));
return new MemberContext(findMember);
}
}
2-1) OAuth2 Login 을 위한 CustomOAuth2UserDetailsService 추가
CustomUserDetailsService 와 동일하게 역시 Manager 를 대신해 실제 인증 로직을 처리한다. Form Login 과 비슷하지만 다른데, Form Login 의 loadUsername() 메서드는 자체 회원가입 처리를 담당하지 않는 인증 과정이라면, OAuth2 Login 의 loadUser() 는 회원가입 처리 + 중복 유저 검증 + 인증 과정을 담당하는 것의 차이가 있다. 역시 Bean 객체로 등록하고 구현 ! 아래 코드는 다중 OAuth2 Login 구현을 위한 코드가 추가되어있는데, 2-2) 에서 자세히 작성해보자.
package com.market.carrot.login.config.customAuthentication.oauth2;
import ...
@RequiredArgsConstructor
@Service
public class CustomOAuth2UserDetailsService extends DefaultOAuth2UserService {
private final LoginRepository loginRepository;
private Member member;
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
OAuth2User oAuth2User = super.loadUser(userRequest);
String providerName = userRequest.getClientRegistration().getRegistrationId();
CheckOAuthProvider OAuthType = checkType(providerName);
OAuth2UserInfo userInfo = OAuthType.getUserInfo(oAuth2User);
String providerId = userInfo.getProviderId();
String username = providerName + "_" + providerId;
String password = "";
String email = userInfo.getEmail();
Role role = Role.USER;
Optional<Member> findMember = loginRepository.findByUsername(username);
// 없다면 회원을 만들고 DB 에 저장 후 MemberContext 에 넣기위해 반환.
if (findMember.isEmpty()) {
member = saveMemberInfo(username, password, email, role);
}
// 이미 있다면 그 회원을 가져오고 member 변수에 저장
findMember.ifPresent(value -> member = value);
return new MemberContext(member, oAuth2User.getAttributes());
}
private CheckOAuthProvider checkType(String provider) {
return new OAuthFactory().getProvider(provider);
}
private Member saveMemberInfo(String username, String password, String email, Role role) {
Member member = Member.createMember(
username, password, email, role);
loginRepository.save(member);
return member;
}
}
2-1) 다중 OAuth2 Login 구현
아래 코드는 다중 OAuth2 Login 을 구현하기 전 코드인데, attribute 값을 추출하기 위해 하드코딩이 되어있는 딱딱한 코드이다. 여러 플랫폼에서 OAuth2 Login 을 제공하고 있는데, 모두 Attribute 값이 다르기 때문에 하드코딩 해버리면 가독성이 굉장히 떨어지게 될 것이다. 따라서 다중으로 OAuth2 API 를 사용하게 되면 "sub", "email" 과 같이 직접적으로 문자열을 사용하지 않게 리팩토링이 필요하다.
... 중략
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
OAuth2User oAuth2User = super.loadUser(userRequest);
String providerName = userRequest.getClientRegistration().getRegistrationId();
String providerId = oAuth2User.getAttribute("sub");
String username = providerName + "_" + providerId;
String password = "";
String email = oAuth2User.getAttribute("email");
Role role = Role.USER;
... 중략
2-2) 코드 리팩토링 과정
디자인 패턴 중 Factory pattern 을 사용해 리팩토링 해보자 !
먼저 어떤 플랫폼에서 로그인 하는지 판단해야한다. 플랫폼을 Provider 라고 생각하고, 여러 Provider 를 모아두는 Factory 를 만들자.
public interface ProviderFactory {
CheckOAuthProvider getProvider(String provider);
}
public class OAuthFactory implements ProviderFactory {
@Override
public CheckOAuthProvider getProvider(String provider) {
if (provider.equals("google")) {
return new ProviderByGoogle();
} else if (provider.equals("github")) {
return new ProviderByGitHub();
}
return null;
}
}
이렇게 하면 매개변수로 어떤 Provider 인지 받으면 getProvider() 를 통해 알맞은 Provider 객체를 생성할 수 있다.
이제 attribute 를 셋팅할 수 있는 ~UserInfo() 객체를 생성할 수 있도록 Factory 를 통해 생성된 구현체를 만들어주자.
public interface CheckOAuthProvider {
OAuth2UserInfo getUserInfo(OAuth2User oAuth2User);
}
public class ProviderByGoogle implements CheckOAuthProvider {
@Override
public OAuth2UserInfo getUserInfo(OAuth2User oAuth2User) {
return new GoogleUserInfo(oAuth2User.getAttributes());
}
}
------
public class ProviderByGitHub implements CheckOAuthProvider {
@Override
public OAuth2UserInfo getUserInfo() {
return new GitHubUserInfo(oAuth2user.getAttributes());
}
}
마지막으로 attribute 를 셋팅할 수 있도록 ~UserInfo 객체를 만들어주면 끝!
public interface OAuth2UserInfo {
String getProviderId();
String getEmail();
String getName();
}
@RequiredArgsConstructor
public class GoogleUserInfo implements OAuth2UserInfo {
private final Map<String, Object> attributes;
@Override
public String getProviderId() {
return (String) attributes.get("sub");
}
@Override
public String getEmail() {
return (String) attributes.get("email");
}
@Override
public String getName() {
return (String) attributes.get("name");
}
}
2-3) 코드 리팩토링 결과
리팩토링을 거쳐 클라이언트 코드인 loadUser() 에서 if - else 와 같은 분기문 없이, 그리고 문자열 없이 알맞은 Provider 에 맞춤 객체를 생성해 처리할 수 있도록 개선되었다.
리팩토링 이전 코드)
... 중략
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
OAuth2User oAuth2User = super.loadUser(userRequest);
String providerName = userRequest.getClientRegistration().getRegistrationId();
String providerId = oAuth2User.getAttribute("sub");
String username = providerName + "_" + providerId;
String password = "";
String email = oAuth2User.getAttribute("email");
Role role = Role.USER;
... 중략
리팩토링 이후 코드)
... 중략
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
OAuth2User oAuth2User = super.loadUser(userRequest);
String providerName = userRequest.getClientRegistration().getRegistrationId();
// 적용 부분
CheckOAuthProvider OAuthType = checkType(providerName);
OAuth2UserInfo userInfo = OAuthType.getUserInfo(oAuth2User);
//
String providerId = userInfo.getProviderId();
String username = providerName + "_" + providerId;
String password = "";
String email = userInfo.getEmail();
Role role = Role.USER;
... 중략
'프로젝트 기록 > 당근마켓 API 서버 기록' 카테고리의 다른 글
[당근마켓 API 서버 Project] 지금까지 구현 내역과 앞으로 구현 계획 (0) | 2023.03.08 |
---|