-
[Spring boot] - 소셜로그인(Naver,Google) + 일반로그인 구현Spring 2024. 7. 23. 23:11반응형
SpringBoot 소셜로그인(Naver,Google) + 일반로그인 구현
소셜로그인과 일반 로그인을 동시에 구현해보자!
build.gradle
plugins { id 'java' id 'org.springframework.boot' version '3.2.5' id 'io.spring.dependency-management' version '1.1.4' } group = 'com.example' version = '0.0.1-SNAPSHOT' java { sourceCompatibility = '17' } configurations { compileOnly { extendsFrom annotationProcessor } } repositories { mavenCentral() } dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-mustache' compileOnly 'org.projectlombok:lombok' runtimeOnly 'com.mysql:mysql-connector-j' annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-test' } tasks.named('test') { useJUnitPlatform() }
build.gradle 에서 필요한 dependencies들을 추가해주자. 여기서 화면을 간단하게 테스트하기위해 mustache를 사용하였다.
application.yml
server: port: 8080 servlet: context-path: / encoding: charset: UTF-8 enabled: true force: true spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://127.0.0.1:3307/boost username: [username] password: [userpassword] mvc: view: prefix: /templates/ suffix: .mustache jpa: hibernate: ddl-auto: create #create update none naming: physical-strategy: org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl show-sql: true security: oauth2: client: registration: google: client-id: [발급받은 client ID] client-secret: [발급받은 secret] scope: - profile - email naver: client-id: [발급받은 client ID] client-secret: [발급받은 secret] scope: - name - email - profile_image client-name: Naver authorization-grant-type: authorization_code redirect-uri: http://localhost:8080/login/oauth2/code/naver provider: naver: authorization-uri: https://nid.naver.com/oauth2.0/authorize token-uri: https://nid.naver.com/oauth2.0/token user-info-uri: https://openapi.naver.com/v1/nid/me user-name-attribute: response
우선 datasource를 설정해주고, mvc에서 mustache를 쓰기 위해 위와같이 설정해준다.
자 이제 코드들을 살펴보자
프로젝트 구조도
com.example.oauth2_test │ ├── config │ ├── auth │ │ ├── PrincipalDetails.java │ │ └── PrincipalDetailsService.java │ ├── oauth │ │ ├── provider │ │ │ ├── GoogleUserInfo.java │ │ │ ├── NaverUserInfo.java │ │ │ └── OAuth2UserInfo.java │ │ ├── PrincipleOauth2UserService.java │ │ └── SecurityConfig.java │ └── WebMvcConfig.java │ ├── controller │ └── IndexController.java │ ├── model │ └── User.java │ ├── repository │ └── UserRepository.java │ └── OAuth2TestApplication.java
PrincipalDetails
package com.example.oauth2_test.config.auth; import com.example.oauth2_test.model.User; import lombok.Data; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.oauth2.core.user.OAuth2User; import java.util.ArrayList; import java.util.Collection; import java.util.Map; /** * PrincipalDetails는 UserDetails와 OAuth2User 인터페이스를 구현하여 * 사용자 인증 및 권한 정보를 담는 역할을 합니다. */ @Data public class PrincipalDetails implements UserDetails, OAuth2User { private User user; private Map<String, Object> attributes; // 일반 로그인 public PrincipalDetails(User user) { this.user = user; } // OAuth2 로그인 public PrincipalDetails(User user, Map<String, Object> attributes) { this.user = user; this.attributes = attributes; } // 해당 User의 권한을 리턴하는 메서드 @Override public Collection<? extends GrantedAuthority> getAuthorities() { Collection<GrantedAuthority> collect = new ArrayList<>(); collect.add(() -> user.getRole()); return collect; } @Override public String getPassword() { return null; } @Override public String getUsername() { return user.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; } @Override public Map<String, Object> getAttributes() { return attributes; } @Override public String getName() { return null; } }
UserDetails, OAuth2User 를 동시에 상속받는 이유?
Spring Security에서 일반 로그인과 OAuth2 로그인에 대해 각각 다른 인증 방식을 사용하기 때문입니다.
1. 일반 로그인 (UserDetails): Spring Security는 일반 로그인 시 UserDetailsService를 통해 사용자 정보를 로드하고, 이를 UserDetails 타입의 객체로 다룹니다. UserDetails 인터페이스는 일반 로그인 시 필요한 사용자 정보를 제공하기 위해 사용됩니다.
2. 소셜 로그인 (OAuth2User): OAuth2 로그인 시에는 Spring Security가 OAuth2UserService를 통해 사용자 정보를 로드하고, 이를 OAuth2User 타입의 객체로 다룹니다. OAuth2User 인터페이스는 OAuth2 로그인 시 필요한 사용자 정보를 제공하기 위해 사용됩니다.
GoogleUserInfo
package com.example.oauth2_test.config.oauth.provider; import java.util.Map; /** * GoogleUserInfo는 OAuth2UserInfo 인터페이스를 구현하여 * Google OAuth2 사용자 정보를 추출합니다. */ public class GoogleUserInfo implements OAuth2UserInfo { private Map<String, Object> attributes; public GoogleUserInfo(Map<String, Object> attributes) { this.attributes = attributes; } @Override public String getProviderId() { return (String) attributes.get("sub"); } @Override public String getProvider() { return "google"; } @Override public String getEmail() { return (String) attributes.get("email"); } @Override public String getName() { return (String) attributes.get("name"); } @Override public String getUserImgUrl() { return (String) attributes.get("picture"); } }
NaverUserInfo
package com.example.oauth2_test.config.oauth.provider; import java.util.Map; /** * NaverUserInfo는 OAuth2UserInfo 인터페이스를 구현하여 * Naver OAuth2 사용자 정보를 추출합니다. */ public class NaverUserInfo implements OAuth2UserInfo { private Map<String, Object> attributes; public NaverUserInfo(Map<String, Object> attributes) { this.attributes = attributes; } @Override public String getProviderId() { return (String) attributes.get("id"); } @Override public String getProvider() { return "naver"; } @Override public String getEmail() { return (String) attributes.get("email"); } @Override public String getName() { return (String) attributes.get("name"); } @Override public String getUserImgUrl() { return (String) attributes.get("profile_image"); } }
OAuth2UserInfo
package com.example.oauth2_test.config.oauth.provider; /** * OAuth2UserInfo는 OAuth2 사용자 정보를 추출하는 메서드를 정의하는 인터페이스입니다. */ public interface OAuth2UserInfo { String getProviderId(); String getProvider(); String getEmail(); String getName(); String getUserImgUrl(); }
PrincipleOauth2UserService
package com.example.oauth2_test.config.oauth; import com.example.oauth2_test.config.auth.PrincipalDetails; import com.example.oauth2_test.config.oauth.provider.GoogleUserInfo; import com.example.oauth2_test.config.oauth.provider.NaverUserInfo; import com.example.oauth2_test.config.oauth.provider.OAuth2UserInfo; import com.example.oauth2_test.model.User; import com.example.oauth2_test.repository.UserRepository; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; import org.springframework.security.oauth2.core.OAuth2AuthenticationException; import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.stereotype.Service; /** * PrincipleOauth2UserService는 DefaultOAuth2UserService를 상속받아 * OAuth2 로그인 후 사용자 정보를 처리합니다. */ @Service public class PrincipleOauth2UserService extends DefaultOAuth2UserService { @Autowired private BCryptPasswordEncoder bCryptPasswordEncoder; @Autowired private UserRepository userRepository; @Override public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { OAuth2User oAuth2User = super.loadUser(userRequest); OAuth2UserInfo oAuth2UserInfo = null; if (userRequest.getClientRegistration().getRegistrationId().equals("google")) { oAuth2UserInfo = new GoogleUserInfo(oAuth2User.getAttributes()); } else if (userRequest.getClientRegistration().getRegistrationId().equals("naver")) { oAuth2UserInfo = new NaverUserInfo((Map) oAuth2User.getAttributes().get("response")); } String provider = oAuth2UserInfo.getProvider(); String providerId = oAuth2UserInfo.getProviderId(); String username = provider + "_" + providerId; String userImgUrl = oAuth2UserInfo.getUserImgUrl(); String email = oAuth2UserInfo.getEmail(); String role = "ROLE_USER"; User userEntity = userRepository.findByUsername(username); if (userEntity == null) { userEntity = User.builder() .username(username) .userImgUrl(userImgUrl) .email(email) .role(role) .provider(provider) .providerId(providerId) .build(); userRepository.save(userEntity); } return new PrincipalDetails(userEntity, oAuth2User.getAttributes()); } }
SpringSecurity에서 ROLE이 필요한 이유
1. 보안 강화: 민감한 정보나 기능에 대한 접근을 제한하여 보안을 강화합니다.
2. 관리 용이성: 역할에 따라 접근 권한을 부여하므로, 사용자 권한 관리를 더 쉽게 할 수 있습니다.
3. 유연성: 다양한 역할과 권한을 정의하여 세분화된 접근 제어를 구현할 수 있습니다.loadUser메서드는 무엇인가?
loadUser메서드는 OAuth2 인증이 완료된 후 사용자 정보를 로드하기 위해 Spring Security가 자동으로 호출하는 메서드입니다. 이 메서드는 OAuth2 인증 프로세스의 일부로, 사용자 정보를 데이터베이스에 저장하거나 업데이트하는 등의 후처리를 수행할 수 있습니다.
비밀번호 암호화를 꼭 해야하는가?
Spring Security는 보안 강화를 위해 비밀번호를 암호화하여 저장할 것을 권장합니다.
UserRequest에서 getClientRegistration의 값을 얻어오는 원리
OAuth2UserRequest 객체는 OAuth2 인증 요청에 대한 정보를 담고 있습니다.
getClientRegistration 메서드를 호출하면, 현재 인증 요청을 처리하고 있는 클라이언트의 등록 정보를 반환합니다. 이 등록 정보에는 클라이언트 ID, 클라이언트 비밀키, 인증 서버의 URL 등 OAuth2 클라이언트 설정이 포함되어 있습니다.
SecurityConfig
package com.example.oauth2_test.config; import com.example.oauth2_test.config.oauth.PrincipleOauth2UserService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.web.SecurityFilterChain; /** * SecurityConfig는 Spring Security 설정을 담당합니다. */ @Configuration @EnableWebSecurity @EnableMethodSecurity(securedEnabled = true, prePostEnabled = true) public class SecurityConfig { @Autowired private PrincipleOauth2UserService principleOauth2UserService; @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.authorizeHttpRequests() .requestMatchers("/user/**").authenticated() .requestMatchers("/manager/**").hasAnyAuthority("ROLE_ADMIN", "ROLE_MANAGER") .requestMatchers("/admin/**").hasAnyAuthority("ROLE_ADMIN") .anyRequest().permitAll() .and() .formLogin().loginPage("/loginForm") .loginProcessingUrl("/login") .defaultSuccessUrl("/") .and() .oauth2Login() .loginPage("/loginForm") .userInfoEndpoint() .userService(principleOauth2UserService); http.csrf().disable(); return http.build(); } }
filterChain메서드는 무엇인가?
filterChain 메서드는 Spring Security에서 HTTP 보안 구성을 정의하는 메서드입니다. 이 메서드는 보안 필터 체인을 설정하고 반환하며, 애플리케이션의 보안 규칙을 정의합니다.
1. HTTP 보안 설정: HttpSecurity 객체를 사용하여 URL 접근 권한, 로그인 페이지, 로그아웃 설정, CSRF 보호 등 다양한 보안 설정을 정의합니다.필터
2. 체인 생성: 설정이 완료된 HttpSecurity 객체를 빌드하여 보안 필터 체인을 생성하고 반환합니다.@EnableWebSecurity: Spring Security를 활성화합니다.
@EnableMethodSecurity(securedEnabled = true, prePostEnabled = true): @Secured 및 @PreAuthorize 어노테이션을 활성화하여 메서드 수준의 보안 설정을 가능하게 합니다.requestMatchers(). authenticated 를 이용하여 경로를 설정한다면 해당 경로에대한 요청은 인증된 사용자만 접근이 가능하도록 설정이 됩니다.
anyRequest().permitAll()의 경우 이전에 설정했던 인증이 필요한 경로를 제외한 경로들에 있어 모두 접근을 가능하도록 설정합니다.
.formLogin().loginPage("/loginForm"): 사용자 정의 로그인 페이지를 설정
.oauth2Login(): OAuth2 로그인을 설정
.loginPage("/loginForm"): OAuth2 로그인 시 사용자 정의 로그인 페이지를 설정
.userInfoEndpoint(): OAuth2 사용자 정보 엔드포인트를 설정
.userService(principleOauth2UserService): OAuth2 로그인 후 사용자 정보를 처리할 서비스를 설정
.http.csrf().disable(): CSRF 보호를 비활성화
PrincipalDetailsService
package com.example.oauth2_test.config.auth; import com.example.oauth2_test.model.User; import com.example.oauth2_test.repository.UserRepository; import org.springframework.beans.factory.annotation.Autowired; 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.Service; /** * PrincipalDetailsService는 UserDetailsService를 구현하여 * 사용자 인증 시 사용자 정보를 로드합니다. */ @Service public class PrincipalDetailsService implements UserDetailsService { @Autowired private UserRepository userRepository; @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { User userEntity = userRepository.findByUsername(username); if (userEntity != null) { return new PrincipalDetails(userEntity); } throw new UsernameNotFoundException("User not found with username: " + username); } }
WebMvcConfig
package com.example.oauth2_test.config; import org.springframework.boot.web.servlet.view.MustacheViewResolver; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.ViewResolverRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; /** * WebMvcConfig는 뷰 리졸버 설정을 담당합니다. */ @Configuration public class WebMvcConfig implements WebMvcConfigurer { @Override public void configureViewResolvers(ViewResolverRegistry registry) { MustacheViewResolver resolver = new MustacheViewResolver(); resolver.setCharset("UTF-8"); resolver.setContentType("text/html;charset=UTF-8"); resolver.setPrefix("classpath:/templates/"); resolver.setSuffix(".html"); registry.viewResolver(resolver); } }
IndexController
package com.example.oauth2_test.controller; import com.example.oauth2_test.config.auth.PrincipalDetails; import com.example.oauth2_test.model.User; import com.example.oauth2_test.repository.UserRepository; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.access.annotation.Secured; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.Authentication; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.ResponseBody; /** * IndexController는 로그인, 회원가입 및 권한에 따른 접근을 처리하는 컨트롤러입니다. */ @Controller public class IndexController { @Autowired private UserRepository userRepository; // UserRepository는 JPA에 의해 Bean으로 등록되어 있어서 @Autowired로 가져와서 사용 @Autowired private BCryptPasswordEncoder bCryptPasswordEncoder; // 비밀번호 암호화를 위한 BCryptPasswordEncoder @GetMapping("/test/login") public @ResponseBody String testLogin( Authentication authentication, @AuthenticationPrincipal PrincipalDetails userDetails) { // DI를 통해 주입된 authentication 객체에서 principal을 가져와 PrincipalDetails로 캐스팅하여 사용 PrincipalDetails principalDetails = (PrincipalDetails) authentication.getPrincipal(); return "세션 정보 확인하기"; } @GetMapping("/test/oauth/login") public @ResponseBody String testOAuthLogin( Authentication authentication, @AuthenticationPrincipal OAuth2User oauth) { // OAuth2 로그인 테스트 OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal(); return "OAuth 세션 정보 확인하기"; } @GetMapping({"", "/"}) public String index() { // 기본 페이지로 이동 return "index"; // src/main/resources/templates/index.mustache로 연결됨 } // OAuth 로그인을 해도 PrincipalDetails로 받을 수 있음 @GetMapping("/user") public @ResponseBody String user(@AuthenticationPrincipal PrincipalDetails principalDetails) { return "user"; } @GetMapping("/admin") public @ResponseBody String admin() { return "admin"; } @GetMapping("/manager") public @ResponseBody String manager() { return "manager"; } @GetMapping("/loginForm") public String loginForm() { return "loginForm"; // 로그인 폼 페이지로 이동 } @GetMapping("/joinForm") public String joinForm() { return "joinForm"; // 회원가입 폼 페이지로 이동 } @PostMapping("/join") public String join(User user) { // 회원가입 처리 String rawPassword = user.getPassword(); // 비밀번호를 가져와서 String encPassword = bCryptPasswordEncoder.encode(rawPassword); // 암호화 user.setRole("ROLE_USER"); // 기본 역할 설정 user.setPassword(encPassword); // 암호화된 비밀번호 설정 userRepository.save(user); // 사용자 정보 저장 return "redirect:/loginForm"; // 로그인 폼 페이지로 리다이렉트 } @Secured("ROLE_ADMIN") @GetMapping("/info") public @ResponseBody String info() { return "개인정보"; // 관리자만 접근 가능 } @PreAuthorize("hasRole('ROLE_MANAGER') or hasRole('ROLE_ADMIN')") @GetMapping("/data") public @ResponseBody String data() { return "데이터정보"; // 관리자와 매니저만 접근 가능 } }
index
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>Title</title> </head> <body> <h1>인덱스 페이지입니다.</h1> </body> </html>
joinForm
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>로그인페이지</title> </head> <body> <h1> 회원가입 페이지</h1> <hr/> <form action="/join" method="POST"> <input type="text" name="username" placeholder="Username"/><br> <input type="password" name="password" placeholder="Password"/><br> <input type="email" name="email" placeholder="Email"/><br> <button>회원가입</button> </form> </body> </html>
LoginForm
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>로그인페이지</title> </head> <body> <h1> 로그인 페이지</h1> <hr/> <form action="/login" method="POST"> <input type="text" name="username" placeholder="Username"/><br> <input type="password" name="password" placeholder="Password"/><br> <button>로그인</button> </form> <a href="/oauth2/authorization/google">구글 로그인</a> <a href="/oauth2/authorization/naver">네이버 로그인</a> <!--https://nid.naver.com/oauth2.0/authorize 이주소가 실행 됨 --> <a href="/joinForm">회원가입을 아직 하지 않으셨나요?</a> </body> </html>
이와같이 일반로그인과 소셜로그인을 동시에 구현해보았다.
반응형'Spring' 카테고리의 다른 글
초간단 SpringBoot DB데이터 Excel 다운로드 (0) 2024.10.25 SpringBoot 초간단 엑셀 다운로드 기능 구현(Apache POI) (0) 2024.10.25 [SpringBoot] - 구글, 네이버 소셜로그인 구현 설정 (0) 2024.07.23 [SpringBoot] - 소셜로그인작동 원리 (0) 2024.07.22 [Spring Boot] - 초간단 휴대폰 인증 기능 구현 (coolsms-건당20원) (0) 2024.06.14