[Spring/Security] 소셜 로그인
- 개발환경
- eclipse
- Spring Security
- Maven
- jsp
0. 소셜 로그인 과정
OAuth2 : 외부 서비스로 소셜 로그인을 이용해 사용자 연동 처리 하는 프로토콜
카카오에서 발급받은 REST API 키를 애플리케이션에 전달해 인가코드 받음
→ 받은 인가코드를 ‘리다이렉트 URI’로 지정된 곳으로 전달
→ 인가코드 + 비밀키 = Access Token 생성
→ 액세스 토큰으로 사용자 인증
→ 사용자 정보 제공
→ 사용자 정보를 데이터베이스에 저장
→ 취득된 사용자 정보 로그인 완료
1. 라이브러리 설정
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
2. application.yml 설정
따로 분리 : 보안 설정한 것은 gitignore 하기 위해서
* application.yml
spring:
profiles:
include:
- auth
* application-auth.yml
spring:
security:
oauth2:
client:
registration:
kakao:
authorization-grant-type: authorization_code
client-id: [발급받은 id 키]
client-secret: [발급받은 secret 키]
client-authentication-method: POST
redirect-uri: http://localhost/login/oauth2/code/kakao
scope:
- profile_nickname
- profile_image
- account_email
client-name: Kakao
provider:
kakao:
authorization-uri: https://kauth.kakao.com/oauth/authorize
token-uri: https://kauth.kakao.com/oauth/token
user-info-uri: https://kapi.kakao.com/v2/user/me
user-name-attribute: id
3. OAuth2UserInfo
- 카카오 로그인 시의 정보 (attributes)를 저장하기 위함
- 이 클래스를 상속받아서 카카오, 구글, 네이버 UserInfo 객체 생성
package com.example.security.oauth.info;
import java.util.Map;
public abstract class OAuth2UserInfo {
private Map<String, Object> attributes;
public OAuth2UserInfo(Map<String, Object> attributes) {
this.attributes = attributes;
}
public Map<String, Object> getAttributes() {
return attributes;
}
public abstract String getId();
public abstract String getName();
public abstract String getEmail();
public abstract String getPhoto();
}
4. KakaoOAuth2UserInfo
* 로그인 시 정보 (attributes)
package com.example.security.oauth.info;
import java.util.Map;
public class KakaoOAuth2UserInfo extends OAuth2UserInfo {
public KakaoOAuth2UserInfo(Map<String, Object> attributes) {
super(attributes);
}
@Override
public String getId() {
return getAttributes().get("id").toString();
}
@Override
@SuppressWarnings("unchecked")
public String getName() {
Map<String, Object> properties = (Map<String, Object>) getAttributes().get("properties");
if(properties == null) {
return null;
}
return (String) properties.get("nickname");
}
@Override
@SuppressWarnings("unchecked")
public String getEmail() {
Map<String, Object> kakaoAccount = (Map<String, Object>) getAttributes().get("kakao_account");
if(kakaoAccount == null) {
return null;
}
return (String) kakaoAccount.get("email");
}
@Override
@SuppressWarnings("unchecked")
public String getPhoto() {
Map<String, Object> properties = (Map<String, Object>) getAttributes().get("properties");
if(properties == null) {
return null;
}
return (String) properties.get("profile_image");
}
}
5. CustomOAuth2UserService
- OAuth2 공급자로부터 소셜 로그인한 정보 받아서 처리 후 CustomOAtuth2User객체 반환
- 소셜로그인의 providerType에 따라 각 소셜로그인 구현객체(KakaoOAuth2UserInfo)에서 로그인정보(attributes)를 추출
- DB에 해당 아이디로 저장된 정보가 있으면 → 최신 정보로 업데이트
저장된 정보가 없으면 → 회원가입 처리 ( + userRole도 설정 )
package com.example.security.service;
import java.util.Collections;
import java.util.Date;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserService;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Service;
import com.example.mapper.UserMapper;
import com.example.mapper.UserRoleMapper;
import com.example.security.oauth.info.KakaoOAuth2UserInfo;
import com.example.security.oauth.info.OAuth2UserInfo;
import com.example.security.vo.CustomOAuth2User;
import com.example.vo.User;
import com.example.vo.UserRole;
import lombok.extern.slf4j.Slf4j;
// 소셜 로그인한 정보 처리 후 OAuth2 객체 반환
@Slf4j
@Service
public class CustomOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
@Autowired
private UserMapper userMapper;
@Autowired
private UserRoleMapper userRoleMapper;
@Override
public OAuth2User loadUser(OAuth2UserRequest oAuth2UserRequest) throws OAuth2AuthenticationException {
// 기본적으로 제공되는 DefaultOAuth2UserSevice 사용 : OAuth 2.0 공급자로부터 받은 액세스 토큰을 사용하여 사용자 정보를 가져옴
OAuth2UserService<OAuth2UserRequest, OAuth2User> oAuth2UserService = new DefaultOAuth2UserService();
OAuth2User oAuth2User = oAuth2UserService.loadUser(oAuth2UserRequest);
// 현재 사용자가 로그인한 OAuth 2.0 서비스의 ID를 가져옴
String providerType = oAuth2UserRequest.getClientRegistration().getRegistrationId();
OAuth2UserInfo userInfo = null;
if (providerType.equals("kakao")) {
userInfo = new KakaoOAuth2UserInfo(oAuth2User.getAttributes());
}
User savedUser = userMapper.getUserById(userInfo.getId());
// 해당 아이디의 사용자가 이미 있을 경우 최신정보로 업데이트
if(savedUser != null) {
if(!providerType.equals(savedUser.getProviderType())) {
throw new OAuth2AuthenticationException("소셜로그인 공급자가 일치하지 않습니다.");
}
updateUser(savedUser, userInfo);
// 해당 아이디의 사용자가 없을 경우 사용자 등록(가입)
} else {
savedUser = createUser(userInfo, providerType);
}
UserRole userRole = userRoleMapper.getUserRoleByNo(savedUser.getNo());
// 로그인한 사용자 객체, 사용자의 권한 정보, 소셜 로그인에 사용된 사용자 정보를 담아서 CustomOAuth2User 객체 생성
return new CustomOAuth2User(
savedUser,
Collections.singletonList(new SimpleGrantedAuthority(userRole.getRoleName())),
userInfo.getAttributes());
}
private User createUser(OAuth2UserInfo userInfo, String providerType) {
// user 정보를 먼저 등록하고
User user = new User();
user.setId(userInfo.getId());
user.setName(userInfo.getName());
user.setEmail(userInfo.getEmail());
user.setPhoto(userInfo.getPhoto());
user.setProviderType(providerType);
user.setCreatedDate(new Date());
user.setUpdatedDate(new Date());
userMapper.insertUser(user);
// 등록된 user의 no를 가지고 userRole 등록
UserRole userRole = new UserRole(user.getNo(), "ROLE_USER");
userRoleMapper.insertUserRole(userRole);
return user;
}
private User updateUser(User user, OAuth2UserInfo userInfo) {
if (userInfo.getName() != null && user.getName().equals(userInfo.getName())) {
user.setName(userInfo.getName());
user.setEmail(userInfo.getEmail());
user.setPhoto(userInfo.getPhoto());
}
userMapper.updateUser(user);
return user;
}
}
6. CustomOAuth2User
- UserDetails처럼 LoginUser를 상속, OAuth2User를 구현
- OAuth2UserService의 load 메소드 실행 → OAuth2User타입인 이 객체 반환
- 얘가 Authentication의 principal에 담김
- 이 객체의 getName() 호출 → 로그인한 유저의 아이디(pk)
이 객체의 getNickName() 호출 → 로그인한 유저의 이름 (부모객체인 LoginUser에 저장된 것)
package com.example.security.vo;
import java.util.Collection;
import java.util.Map;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.oauth2.core.user.OAuth2User;
import com.example.vo.User;
public class CustomOAuth2User extends LoginUser implements OAuth2User{
private Collection<GrantedAuthority> authorites;
private Map<String, Object> attributes;
public CustomOAuth2User(User user, Collection<GrantedAuthority> authorities, Map<String, Object> attributes) {
super(user);
this.authorites = authorities;
this.attributes = attributes;
}
@Override
public Map<String, Object> getAttributes() {
return attributes;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return authorites;
}
@Override
public String getName() { // authenticated principal의 name(pk값)
return getId();
}
@Override
public String getProviderType() {
return super.getProviderType();
}
}
7. LoginUser
- 로그인한 사용자의 정보가 여기에 담김
- UserDetails 객체, CustomOAuth2User 객체가 얘를 상속받음 → 똑같은 객체로 로그인한 사용자의 정보를 사용하기 위해
- @AuthenticatedUser LoginUser loginUser : 인증된 로그인한 사용자의 정보 사용
package com.example.security.vo;
import com.example.vo.User;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class LoginUser {
private String id;
private String nickname;
private String providerType;
private String email;
public LoginUser() {}
public LoginUser(User user) {
this.id = user.getId();
this.nickname = user.getName();
this.providerType = user.getProviderType();
this.email = user.getEmail();
}
}
8. SecurityConfig
- OAuth2 로그인 설정
package com.example.security;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
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.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import com.example.security.service.CustomEmployeeDetailsService;
import com.example.security.service.CustomOAuth2UserService;
import com.example.security.service.CustomUserDetailsService;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
PasswordEncoder passwordEncoder;
@Autowired
CustomUserDetailsService userDetailsService;
@Autowired
CustomEmployeeDetailsService employeeDetailsService;
@Autowired
CustomOAuth2UserService customOAuth2UserService;
@Override
protected void configure(HttpSecurity http) throws Exception {
http
// .csrf().disable()
// 인가정책 설정
.authorizeRequests()
// 다음 페이지에 모두 접근 가능
.antMatchers("/").permitAll()
.antMatchers("/user/register", "/user/registered").permitAll()
.antMatchers("/user/login", "/emp/login").permitAll()
.antMatchers("/oauth2/**").permitAll()
.antMatchers("/login/oauth2/**").permitAll()
// 인증된 사람만 다음 기능 가능
.antMatchers("/logout").authenticated()
// 특정 권한을 가진 사람만 다음 페이지 접근 가능
.antMatchers("/user/**").hasAnyRole("USER", "ADMIN")
.antMatchers("/emp/**").hasAnyRole("EMP", "ADMIN")
.antMatchers("/admin/**").hasAnyRole("ADMIN")
.anyRequest().permitAll()
.and()
// 인증정책 설정
.formLogin() // Form 로그인
.loginPage("/user/login")
.usernameParameter("id")
.loginProcessingUrl("/login") // 로그인 처리를 요청하는 URI
.and()
// 로그아웃 정책 설정
.logout()
.logoutUrl("/logout") // 로그아웃 처리를 요청하는 URI
.logoutSuccessUrl("/") // 로그아웃 성공 시 재요청할 URI
.and()
.oauth2Login() // OAuth2 로그인
.loginPage("/user/login")
.defaultSuccessUrl("/")
.failureUrl("/user/login")
.userInfoEndpoint() // 로그인 성공 후 사용자정보 가져옴
.userService(customOAuth2UserService); // 가져온 사용자 정보를 처리할 때 사용
}