사전 설정spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/bblog username: ${userDbId} password: ${userDbPw} jpa: hibernate: ddl-auto: create security: oauth2: client: registration: google: client-name: Google client-id: ${googleClientId} client-secret: ${googleClientSecret} redirect-uri: "{baseUrl}/{action}/oauth2/code/{registrationId}" scope: - email - profile kakao: client-name: Kakao client-id: ${kakaoClientId} client-secret: ${kakaoClientSecret} authorization-grant-type: authorization_code client-authentication-method: client_secret_post redirect-uri: "{baseUrl}/{action}/oauth2/code/{registrationId}" scope: - profile_nickname - profile_image naver: client-name: Naver client-id: ${naverClientId} client-secret: ${naverClientSecret} authorization-grant-type: authorization_code client-authentication-method: client_secret_post redirect-uri: "{baseUrl}/{action}/oauth2/code/{registrationId}" scope: - id - nickname - email provider: kakao: authorization-uri: https://kauth.kakao.com/oauth/authorize user-name-attribute: id token-uri: https://kauth.kakao.com/oauth/token user-info-uri: https://kapi.kakao.com/v2/user/me naver: authorization-uri: https://nid.naver.com/oauth2.0/authorize user-name-attribute: response token-uri: https://nid.naver.com/oauth2.0/token user-info-uri: https://openapi.naver.com/v1/nid/me
Config정의@Configuration public class SecurityConfiguration { @Bean public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity){ return httpSecurity .csrf(csrf -> csrf.disable()) .cors(cors -> cors.disable()) .oauth2Login(Customizer.withDefaults()) .authorizeHttpRequests( auth -> auth.requestMatchers("/login", "/sign-up") .anonymous() .requestMatchers("/user/**") .hasAnyAuthority("USER","ADMIN") .requestMatchers("/admin/**") .hasAuthority("ADMIN") .anyRequest() .denyAll() ) .build(); } }
로그인기능 활용하기
Entity Class정의하기
。OAuth 2.0구현 시 보통이메일을식별용 아이디로서데이터의유일한 식별자역할을 수행
▶중복회원을 방지하는 용도로 사용
。카카오는사업자로 등록되지 않으면이메일 계정을 요청할 수 없으므로서브ID로서ID + @kakao.com을 설정하여유일하게 식별하도록 설정@Column(unique = true, nullable = false) private String email;▶
Entity 설계시 다음처럼 설정
。provider 필드는OAuth Provider( =kakao,필드@Getter @Entity @NoArgsConstructor(access = AccessLevel.PROTECTED) public class Member { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String name; private String nickname; private String role = "USER"; @Column(unique = true, nullable = false) private String email; private String provider; @Builder public Member(String name, String nickname, String email, String provider) { this.name = name; this.nickname = nickname; this.email = email; this.provider = provider; } }
DTO역할의OAuth2User정의
。OAuth2UserService( =UserServiceDetails)에 의해 사용되는UserDetails 구현체를 정의 시OAuth2User를구현
OAuth2User
。OAuth Provider에서 제공되는사용자정보에 해당하는attrobutes 필드를 정의
▶OAuth2UserService에 의해 사용자 정보를 포함하여 생성@Getter public class CurretUser implements OAuth2User { private String email; private String name; private Map<String,Object> attributes; @Setter private String role; @Override public Collection<? extends GrantedAuthority> getAuthorities() { return List.of( new SimpleGrantedAuthority("ROLE_" + this.role.name()) ); } @Builder public CurretUser(String email, String name, Map<String, Object> attributes){ this.email = email; this.name = name; this.attributes = attributes; } }
Repository Layer정의@Repository public interface MemberRepository extends JpaRepository<Member,Long> { Optional<Member> findByEmail(String email); default Member findByEmailOrThrow(String email){ return findByEmail(email).orElseThrow(()->new RuntimeException("이메일에 해당하는 계정을 찾지 못했습니다.")); } }▶
카카오는사업자로 등록되지 않으면이메일 계정을 요청할 수 없으므로서브ID로서ID + @kakao.com을 설정하여유일하게 식별하도록 설정
- 각
OAuth Provider의자원 응답 구조에 대한양식을 정의 및커스텀 OAuth2User 구현체를 반환하는팩토리 패턴 클래스정의
。각OAuth Provider의API 문서의응답 구조를 보고 설계
▶kakao/naver는사용자 정보요청 시 반환하는사용자정보의json 구조가 다르므로
。OAuth2UserService에 의해사용자 정보를 포함하여 생성된Oauth2User 구현체를팩토리 패턴 클래스를 통해 각OAuth Provider의응답구조에 대응하는 로직을 각각 정의 후커스텀 OAuth2User 구현체를 생성해서 반환하도록 설정public class MemberDetailsFactory { public static CurretUser memberDetails(String provider, OAuth2User oAuth2User){ Map<String,Object> attributes = oAuth2User.getAttributes(); return switch (provider.toUpperCase()) { case "GOOGLE" -> CurretUser.builder() .email(attributes.get("email").toString()) .name(attributes.get("name").toString()) .attributes(attributes) .build(); case "KAKAO" -> { Map<String, Object> properties = (Map<String, Object>) attributes.get("properties"); // yield CurretUser.builder() // "id"의 경우 "properties"와 동일 레벨에 존재 .email(attributes.get("id").toString() + "@kakao.com") // 별칭을 이메일로서 동적으로 지정 .name(properties.get("nickname").toString()) .attributes(attributes) .build(); } case "NAVER" -> { Map<String, Object> properties = (Map<String, Object>) attributes.get("response"); yield CurretUser.builder() .email(properties.get("email").toString()) .name(properties.get("nickname").toString()) .attributes(attributes) .build(); } default -> throw new IllegalArgumentException("지원하지 않는 제공자입니다. :" + provider); }; } }
Service Layer정의 및회원가입 로직작성
。OAuth를 통한로그인시 해당백엔드 서버에회원가입을 자동으로 수행하여DB에 저장하도록 설정
。이떄,OAuth를 통한로그인시 매번 호출되므로,동일 계정으로로그인시중복 회원가입되는 문제가 발생
▶최초 로그인일때만회원가입을 수행하고, 아닌 경우DB에 존재하는사용자정보를 기반으로Role을 설정한커스텀 OAuth2User생성 및 반환하도록 설정
。동일한이메일이 존재하는 경우 다른Provider의이메일인지 확인하는로직을 작성
ex)naver의wjdtn747@naver.com이 등록된 상태에서wjdtn747@naver.com로 로그인 시 차단@Service @RequiredArgsConstructor @Transactional(readOnly = true) public class OAuthService extends DefaultOAuth2UserService { private final MemberRepository memberRepository; @Override @Transactional public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { // ~ 권한부여승인코드 상 1 ~ 6번이 자동으로 수행 // // 권한부여승인코드 7 ~ 8번 // OAuth2UserRequest의 Access Token, ClientRegistration을 기반으로 // OAuth Provider에게 요청 및 반환받은 사용자정보를 기반으로 OAuth2User 구현체 생성 OAuth2User oAuth2User = super.loadUser(userRequest); // OAuth Provider 정보 가져오기 ( kakao, ... ) String providerId = userRequest.getClientRegistration().getRegistrationId(); // 각 OAuth Provider의 각각 다른 응답구조에 대응하면서 // 커스텀 OAuth2User 구현체를 생성하여 반환하는 Factory 메서드 사용 CurretUser curretUser = MemberDetailsFactory.memberDetails(providerId, oAuth2User); // // 회원가입 분기점 // 각 상호작용 후 currentUser의 Role을 설정해야한다. // String role; if (isNoExistingMember(curretUser.getEmail())) { // 가입된 계정이 없는 경우 회원가입 role = saveMemberByCurrentUserAndReturnRole(providerId, curretUser); } else { // 동일한 이메일이 존재하는 경우 다른 Provider의 이메일인지 확인 // ex) naver의 wjdtn747@naver.com이 등록된 상태에서 // google의 wjdtn747@naver.com로 로그인 시 차단 role = ValidateSameProviderAndReturnRole(providerId, curretUser.getEmail()); } // 커스텀 OAuth2User에 Role 설정 및 로그인 완료 curretUser.setRole(role); return curretUser; } public boolean isNoExistingMember(String email){ return memberRepository.findByEmail(email).isEmpty(); } public String ValidateSameProviderAndReturnRole(String currentProviderId, String email){ Member founded = memberRepository.findByEmailOrThrow(email); if(founded.getProvider().equals(currentProviderId)){ // 동일한 Provider인 경우 Role 반환 return founded.getRole(); } else { // 다른 Provider인데 동일 이메일인 경우 예외 발생 throw new RuntimeException("중복이메일 입니다."); } } public String saveMemberByCurrentUserAndReturnRole(String providerId, CurretUser curretUser){ Member saved = memberRepository.save( Member.builder() .name(curretUser.getName()) .email(curretUser.getEmail()) .provider(providerId) .nickname("별명") .build() ); return saved.getRole(); } }▶ 반환된
OAuth2User 구현체( =UserDetails)는 이후Authentication 구현체의Principal에 포함되어SecurityContextHolder의 현재스레드의SecurityContext에 저장됨
▶Provider : google의wjdtn747@naver.com이 등록된 상태에서Provider : naver의wjdtn747@naver.com이회원가입시중복이메일처리됨