스프링부트 팀플) 20240411 스프링시큐리티+소셜로그인(네이버/카카오
기존의 소셜로그인에서 스프링시큐리티를 더하는 시스템을 구현했다.
생각보다 너무 복잡하고 어렵더라...
심지어 아직 구글은 구현이 안 된다...이유는 몰루
Redirect_Uri가 다르다고 뜨다가 결국 token이 만료되었다고 뜨기에 이르렀다.
아직 시스템도 손볼 것이 많지만 일단은 포스팅
<!-- OAuth2LoginAuthenticationFilter -->
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-client</artifactId>
<version>5.7.7</version>
</dependency>
우선 pom.xml을 수정해서 oauth2 의존성을 추가한다.
#Registeration
#Naver Login
spring.security.oauth2.client.registration.naver.client-name=naver
spring.security.oauth2.client.registration.naver.client-id=XXX
spring.security.oauth2.client.registration.naver.client-secret=XXXspring.security.oauth2.client.registration.naver.redirect-uri=http://localhost:8090/acorn/login/oauth2/code/naver
spring.security.oauth2.client.registration.naver.authorization-grant-type=authorization_code
spring.security.oauth2.client.registration.naver.scope=XXX
#provider
#Naver
spring.security.oauth2.client.provider.naver.authorization-uri=https://nid.naver.com/oauth2.0/authorize
spring.security.oauth2.client.provider.naver.token-uri=https://nid.naver.com/oauth2.0/token
spring.security.oauth2.client.provider.naver.user-info-uri=https://openapi.naver.com/v1/nid/me
spring.security.oauth2.client.provider.naver.user-name-attribute=response
XXX친 부분과 uri를 수정하면 된다.
uri의 경우, 이렇게 안 하면 어떻게 해도 에러가 나더라...
http://localhost:8090/acorn: 기본주소
login: 기본 oauth2 주소(default 주소도 여기에서 연결된다)
code: authorization_code로 받아온다는 뜻
naver: 어디 소셜사이트인지 구별
package com.moonBam.springSecurity;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.access.hierarchicalroles.RoleHierarchy;
import org.springframework.security.access.hierarchicalroles.RoleHierarchyImpl;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.builders.WebSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
@SuppressWarnings("deprecation")
@Configuration
@EnableWebSecurity
public class SecurityConfig { // WebSecurityConfigurerAdapter는 securityFilterChain과 동시 사용 불가
@Autowired
private SpringSecurityService springSecurityService;
private final OAuthService oAuthService;
public SecurityConfig(OAuthService oAuthService) {
this.oAuthService = oAuthService;
}
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
@Bean
public RoleHierarchy roleHierarchy() { //계층형 권한 부여
RoleHierarchyImpl hierarchy = new RoleHierarchyImpl();
hierarchy.setHierarchy("ROLE_ADMIN > ROLE_MEMBER"); //ADMIN: MEMBER의 모든 권한 + ADMIN 고유의 권한
return hierarchy;
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity security) throws Exception {
//configure는 Override의 기본값
security.csrf().disable(); //csrf: 요청을 위조하여 사용자가 원하지 않아도 서버 측으로 특정 요청을 강제로 보내는 방식
// 회원 정보 변경 / 게시글 CRUD를 사용자 모르게 요청
//spring.mustache.servlet.expose-request-attributes=true
//POST요청: Form에서 <input type="hidden" name="_csrf" value="{{_csrf.token}}"/>
//AJAX요청: HTML <head> 아래에 <meta name="_csrf_header" content="{{_csrf.headerName}}"/>
//권한따라 허용되는 url 설정
security.authorizeHttpRequests().antMatchers("/").permitAll();
// security.authorizeHttpRequests().antMatchers("/memberList").hasRole("ADMIN"); //ROLE은 무조건 // ADMIN은 관례
// security.authorizeHttpRequests().anyRequest().permitAll();
//로그인 설정
security.formLogin().loginPage("/mainLogin")
.loginProcessingUrl("/loginProc") // /loginProc으로 Mapping
// 자동으로 @Service가 붙은 Service 중에 loadUserByUsername가 있는 곳을 찾아가서 실행
// jsp에서 Method="POST", Action="/loginProc"으로 처리
.usernameParameter("userId") // username Param을 userId로 사용 가능
.passwordParameter("userPw") // password Param을 userPw로 사용 가능
// .successHandler // 로그인 성공 시, 작동(세션이랑 쿠키 작동 예정)
// .failureHandler // 로그인 실패 시, 작동
.defaultSuccessUrl("/", true); // 로그인 성공 시, 이동하는 URL
//소셜 로그인 설정
security.oauth2Login(oauth2 -> oauth2
.loginPage("/mainLogin")
.userInfoEndpoint((userInfoEndpointConfig) -> userInfoEndpointConfig.userService(oAuthService)));
//세션관리
security.sessionManagement()
.maximumSessions(1) // 최대 동시 접속 1개
.maxSessionsPreventsLogin(true); // True: 동시 접속 시 새로운 로그인 차단
security.sessionManagement().sessionFixation().changeSessionId(); //로그인 시 동일한 세션에 대한 id 변경
//로그인 실패
security.exceptionHandling().accessDeniedPage("/NotAuthentic"); //권한 없으면 가는 페이지
//로그아웃
security.logout().logoutUrl("/Logout").logoutSuccessUrl("/").invalidateHttpSession(true);
security.userDetailsService(springSecurityService);
return security.build();
}
public void configure(WebSecurity web) throws Exception {
web.ignoring().antMatchers("/static/**");
}
}
기존 기능에서 추가된 부분
private final OAuthService oAuthService;
public SecurityConfig(OAuthService oAuthService) {
this.oAuthService = oAuthService;
}
- oauth2를 받기 위한 서비스 연결
//소셜 로그인 설정
security.oauth2Login(oauth2 -> oauth2
.loginPage("/mainLogin")
.userInfoEndpoint((userInfoEndpointConfig) -> userInfoEndpointConfig.userService(oAuthService)));
- .loginPage("/mainLogin")이 없으면 oauth2 기본 로그인 페이지로 연결된다(contextPath + /login)
- .loginPage("/mainLogin")이 있으면 /mainLogin으로 oauth2 로그인 페이지가 연결된다.
--> 확인하기에 가장 좋은 방법은 비로그인 상태에서 권한을 요구하는 페이지 요청을 할 때
.userInfoEndpoint((userInfoEndpointConfig) -> userInfoEndpointConfig.userService(oAuthService)));
- 각 사이트 별로 데이터를 가져온다음에 oAuthService로 전송된다.
- 자동으로 json데이터로 뽑아올 수 있다(진짜 엄청난 기능이다)
기타 추가된 기능
@Bean
public RoleHierarchy roleHierarchy() { //계층형 권한 부여
RoleHierarchyImpl hierarchy = new RoleHierarchyImpl();
hierarchy.setHierarchy("ROLE_ADMIN > ROLE_MEMBER"); //ADMIN: MEMBER의 모든 권한 + ADMIN 고유의 권한
return hierarchy;
}
- 계층형 권한 부여 기능
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity security) throws Exception {
- 기존의 configure에서 보다 사용하기 편한 SecurityFilterChain로 변경
- configure는 고정 메소드 이름이기 때문에 다른 걸로 바꿔야 한다.
/세션관리
security.sessionManagement()
.maximumSessions(1) // 최대 동시 접속 1개
.maxSessionsPreventsLogin(true); // True: 동시 접속 시 새로운 로그인 차단
security.sessionManagement().sessionFixation().changeSessionId(); //로그인 시 동일한 세션에 대한 id 변경
- 세션 관리용(아직 사용하지는 않음)
기존에 소셜로그인을 해서 json데이터를 뽑아올 때는 말도 안되는 절차를 거쳐야 했다.
HTTP요청, ACCESS요청, 토큰 요청을 모두 통과해야 json데이터를 불러올 수 있었고,
과정 하나하나가 쉽지 않았다.
그런데 oauth2는 쉽게 데이터를 불러와준다는 점에서 소셜로그인을 한다면 꼭 알아야 할 부분이라 생각한다.
package com.moonBam.springSecurity;
import org.springframework.beans.factory.annotation.Autowired;
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;
import com.moonBam.controller.member.AnonymousBoardController;
import com.moonBam.controller.member.MailController;
import com.moonBam.controller.member.OpenApiController;
import com.moonBam.controller.member.SecurityController;
import com.moonBam.dao.member.LoginDAO;
import com.moonBam.dao.member.OpenApiDAO;
import com.moonBam.dto.MemberDTO;
import com.moonBam.service.member.OpenApiService;
@Service
public class OAuthService extends DefaultOAuth2UserService {
@Autowired
OpenApiService oas;
@Autowired
OpenApiDAO oad;
@Autowired
MailController mc;
@Autowired
LoginDAO dao;
@Autowired
OpenApiController oac;
@Autowired
SecurityController sc;
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
//소셜에서 JSON데이터 받아오기
OAuth2User oAuth2User = super.loadUser(userRequest);
//소셜에서 받아오는 JSON데이터 확인
System.out.println("oAuth2User: " + oAuth2User.getAttributes());
//등록을 대비하여 미리 선언
MemberDTO register = new MemberDTO();
//어떤 인증 Provider인지 확인
String registrationId = userRequest.getClientRegistration().getRegistrationId();
//어디 소셜인지 확인
System.out.println("registrationId: " + registrationId);
OAuth2Response oAuth2Response = null;
//소셜에 따라 다른 방법으로 데이터 추출
if(registrationId.equals("naver")){
oAuth2Response = new NaverResponse(oAuth2User.getAttributes());
} else
if(registrationId.equals("google")){
oAuth2Response = new GoogleResponse(oAuth2User.getAttributes());
} else
if(registrationId.equals("kakao")){
oAuth2Response = new KakaoResponse(oAuth2User.getAttributes());
}
//유저 아이디를 JSON데이터에서 추출
String userId = oAuth2Response.getEmail();
//유저 아이디가 DB에 있는 것인지 검증
MemberDTO dto = dao.userDetail(userId);
System.out.println("dto: "+dto);
String role = "";
if(dto == null) {
register.setUserId(userId);
register.setUserPw(sc.encrypt(AnonymousBoardController.getNum(16)));
register.setNickname(oac.randomNickname());
register.setSecretCode(sc.encrypt(AnonymousBoardController.getNum(8)));
// 소셜에 따라 연동 체크
if(registrationId.equals("naver")){
register.setNaverConnected(1);
} else
if(registrationId.equals("google")){
register.setGoogleConnected(1);
} else
if(registrationId.equals("kakao")){
register.setKakaoConnected(1);
}
//회원가입
oas.insertAPIMember(register);
//회원가입 메일 송신
try {
mc.RegisterCompleteEmail(register.getUserId(), register.getNickname(), register.getSecretCode());
} catch (Exception e) {
e.printStackTrace();
}
//새로 회원가입한 유저의 Role 전송(기본값: MEMBER)
role = "ROLE_MEMBER";
}
//이미 가입한 유저일 경우
if(dto != null) {
System.out.println("기존유저1: "+dto);
// 소셜에 따라 추가 연동 체크
if(registrationId.equals("naver")){
oad.updateAPIMemberNaverConnected(dto.getUserId());
} else
if(registrationId.equals("google")){
oad.updateAPIMemberGoogleConnected(dto.getUserId());
} else
if(registrationId.equals("kakao")){
oad.updateAPIMemberKakaoConnected(dto.getUserId());
}
System.out.println("기존유저2: "+dto);
//기존 유저의 Role 전송
role = dto.getRole();
}
return new OAuthUser(oAuth2Response, role);
}
}
- 과정 하나하나를 써놨기 때문에 자세한 설명은 생략
- 비효율적인 코드지만 일단 돌아간다. 추후 개선이 필요한 부분
- @Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
--> 여기서 @Override가 아닌 다른 형태로 만들면 인식을 못한다...다른 방법이 있을 텐데...역시 공부가 필요한 부분
package com.moonBam.springSecurity;
public interface OAuth2Response {
String getProvider(); //제공자 이름(naver / google / kakao)
String getEmail();
String getName();
}
- 각 Response에서 사용할 인터페이스
package com.moonBam.springSecurity;
import java.util.Map;
public class NaverResponse implements OAuth2Response{
private final Map<String, Object> attribute;
public NaverResponse(Map<String, Object> attribute) {
this.attribute = (Map<String, Object>) attribute.get("response");
}
@Override
public String getProvider() {
return "naver";
}
@Override
public String getEmail() {
return attribute.get("email").toString();
}
@Override
public String getName() {
return attribute.get("name").toString();
}
}
package com.moonBam.springSecurity;
import java.util.Map;
public class KakaoResponse implements OAuth2Response {
private Map<String, Object> attribute;
public KakaoResponse(Map<String, Object> attributes) {
this.attribute = attributes;
}
@Override
public String getProvider() {
return "kakao";
}
@Override
public String getEmail() {
Map<String, Object> kakaoAccount = (Map<String, Object>) attribute.get("kakao_account");
return (String) kakaoAccount.get("email");
}
@Override
public String getName() {
Map<String, Object> properties = (Map<String, Object>) attribute.get("properties");
return (String) properties.get("nickname");
}
}
package com.moonBam.springSecurity;
import java.util.Map;
public class GoogleResponse implements OAuth2Response {
private final Map<String, Object> attribute;
public GoogleResponse(Map<String, Object> attribute) {
this.attribute = attribute;
}
@Override
public String getProvider() {
return "google";
}
@Override
public String getEmail() {
return attribute.get("email").toString();
}
@Override
public String getName() {
return attribute.get("name").toString();
}
}
각 Response에서 데이터를 뽑아서 oAuth2Response 형태로 만든다.
여기서 만든 데이터로 신규/기존 유저 확인, 신규 회원가입 로직도 이루어진다.
package com.moonBam.springSecurity;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Map;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.oauth2.core.user.OAuth2User;
public class OAuthUser implements OAuth2User {
private final OAuth2Response oAuth2Response;
private final String role;
public OAuthUser(OAuth2Response oAuth2Response, String role) {
this.oAuth2Response = oAuth2Response;
this.role = role;
}
@Override
public Map<String, Object> getAttributes() {
return null;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
Collection<GrantedAuthority> col = new ArrayList<>();
col.add(new GrantedAuthority() {
@Override
public String getAuthority() {
return role;
}
});
return col;
}
@Override
public String getName() {
return oAuth2Response.getName();
}
public String getUsername() {
return oAuth2Response.getEmail();
}
}
여기서 각 칼럼에 넣을 데이터를 정리한다
대표적으로 유저 아이디인 username을 email을 가져와 넣는 것 등
이런 과정을 통해 소셜 사이트 로그인과 스프링시큐리티를 융합할 수 있었다.
다음에 구글까지 제대로 구현이 되면 jsp까지 포함해서 포스팅할 예정
일단 사이트 연결이 기존 소셜 사이트 로그인보다 좀 어렵긴 하다
개선점
- 구글 사이트 로그인 구현
- 회원가입일 경우, 닉네임 변경 페이지로 이동 / 로그인일 경우, 메인으로 이동
- JWT를 구현했을 때, JWT토큰을 통해 로그인 여부 판단하도록 구현