pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.6.6</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.study</groupId>
<artifactId>oauth2</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>oauth2</name>
<description>Demo project for Spring Boot</description>
<properties>
<java.version>11</java.version>
</properties>
<dependencies>
<!-- [AUTH]-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- [DB]-->
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>2.2.2</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<scope>runtime</scope>
<optional>true</optional>
</dependency>
<!-- [ LOMBOK ]-->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
<!-- [ JWT ]-->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.5</version>
</dependency>
<!-- https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt-impl -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<!-- https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt-jackson -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>
application.yml
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/book_management
username: root
password: root
mybatis:
mapper-locations:
- /mappers/*.xml
jwt:
secret: uAdzVUhnjML7pCLQLDapBdNacinrqdRjbaqLD7sMUfe0ILk8KKqk5Xb0WncSuIre
Maven Project Update
SecurityConfig.java
코드를 입력하세요
WebMvcConfig.java
코드를 입력하세요
AuthService
코드를 입력하세요
application.yml (추가)
코드를 입력하세요
npx create-react-app oauth2-front
npm i react-router-dom
npm i axios
npm i react-query
npm i recoil
npm i react-icons
index.js
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
import { QueryClient, QueryClientProvider } from 'react-query';
import { RecoilRoot } from 'recoil';
import { BrowserRouter } from 'react-router-dom';
const queryClient = new QueryClient();
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<RecoilRoot>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
</BrowserRouter>
</QueryClientProvider>
</RecoilRoot>
);
reportWebVitals();
App.js
import React from 'react';
import { Routes, Route } from "react-router-dom";
import NotFound from './pages/NotFound/NotFound';
function App() {
return (
<>
<Routes>
<Route path='/' />
<Route path='/auth/login' />
<Route path='/auth/register' />
<Route path='/auth/oauth2/register' />
<Route path='/*' element={<NotFound />} />
</Routes>
</>
);
}
export default App;
src > components, pages, store 폴더 생성
NotFound > NotFound.js
import React from 'react';
const NotFound = () => {
return (
<div>
<h1>페이지를 찾을 수 없습니다.</h1>
</div>
);
};
export default NotFound;
Login > Login.js
import React from 'react';
import { FcGoogle } from 'react-icons/fc';
import { useNavigate } from 'react-router-dom';
const Login = () => {
const navigate = useNavigate();
const googleAuthClickHandle = () => {
window.location.href="http://localhost:8080/oauth2/authorization/google";
}
return (
<div>
<input type="text" placeholder='email' />
<input type="password" placeholder='password' />
<button>로그인</button>
<button onClick={googleAuthClickHandle}><FcGoogle /></button>
</div>
);
};
export default Login;
SecurityConfig.java (추가)
package com.study.oauth2.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import com.study.oauth2.service.AuthService;
import lombok.RequiredArgsConstructor;
@EnableWebSecurity
@Configuration
@RequiredArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter {
private final AuthService authService;
@Override
protected void configure(HttpSecurity http) throws Exception {
http.httpBasic().disable();
http.formLogin().disable();
http.cors();
http.csrf().disable();
http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
http.authorizeRequests()
.antMatchers("/auth/**")
.permitAll()
.anyRequest()
.authenticated()
.and()
.oauth2Login()
.loginPage("http://localhost:3000/auth/login")
.userInfoEndpoint()
.userService(authService);
}
}
Google Cloud 수정
구현한 로그인 페이지 (구글 접속 버튼)
구글 접속이 정상적으로 이루어졌을 경우
security > OAuth2Attribute.java
package com.study.oauth2.security;
import java.util.HashMap;
import java.util.Map;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.ToString;
@ToString
@Builder(access = AccessLevel.PRIVATE) //option (NoArg에 private 걸어두는 방법)
@Getter
public class OAuth2Attribute {
private Map<String, Object> attributes;
private String email;
private String name;
public static OAuth2Attribute of(String provider, Map<String, Object> attributes) {
switch (provider) {
case "google":
return ofGoogle(attributes);
case "kakao":
return ofKakao(attributes);
case "naver":
return ofNaver( attributes);
default:
throw new RuntimeException();
}
}
private static OAuth2Attribute ofGoogle(Map<String, Object> attributes) {
return OAuth2Attribute.builder()
.name((String) attributes.get("name"))
.email((String) attributes.get("email"))
.attributes(attributes)
.build();
}
private static OAuth2Attribute ofKakao(Map<String, Object> attributes) {
Map<String, Object> kakaoAccount = (Map<String, Object>) attributes.get("kakao_account");
Map<String, Object> kakaoProfile = (Map<String, Object>) kakaoAccount.get("profile");
return OAuth2Attribute.builder()
.name((String) kakaoProfile.get("nickname"))
.email((String) kakaoAccount.get("email"))
.attributes(kakaoAccount)
.build();
}
private static OAuth2Attribute ofNaver( Map<String, Object> attributes) {
Map<String, Object> response = (Map<String, Object>) attributes.get("response");
return OAuth2Attribute.builder()
.name((String) response.get("name"))
.email((String) response.get("email"))
.attributes(response)
.build();
}
public Map<String, Object> convertToMap() {
Map<String, Object> map = new HashMap<>();
map.put("name", name);
map.put("email", email);
return map;
}
}
AuthService (추가)
코드를 입력하세요
변경 전
ArrayList<SimpleGrantedAuthority> authorities = new ArrayList<>();
authorities.add(new SimpleGrantedAuthority("ROLE_USER"));
변경 후
return new DefaultOAuth2User(Collections.singleton(new SimpleGrantedAuthority("ROLE_USER")), Attributes, "email");
SecurityConfig(추가)
코드를 입력하세요
security > OAuth2SuccessHandler.java
코드를 입력하세요
security > jwt > JwtTokenProvider
package com.study.oauth2.security.jwt;
import java.security.Key;
import java.util.Date;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Component;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
@Component
public class JwtTokenProvider {
private final Key key;
public JwtTokenProvider(@Value("${jwt.secret}") String secretKey) {
key = Keys.hmacShaKeyFor(Decoders.BASE64.decode(secretKey));
}
//jwt Token 생성 (회원가입 전용 토큰)
public String generateOAuth2RegisterToken (Authentication authentication) {
// 만료기간
Date tokenExpiresDate = new Date(new Date().getTime() + (1000*60*10));
OAuth2User oAuth2User = (OAuth2User)authentication.getPrincipal();
String email = oAuth2User.getAttribute("email");
System.out.println(email);
return Jwts.builder()
.setSubject("OAuth2Register")
.claim("email", email)
.setExpiration(tokenExpiresDate)
.signWith(key,SignatureAlgorithm.HS256)
.compact();
}
public Boolean validateToken(String token) {
try {
Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token);
return true;
}catch (Exception e) {
}
return false;
}
}
전에 했던 파일에서 복사해오기
front
Register > OAuth2Register.js
import axios from 'axios';
import React, { useState } from 'react';
import { useMutation } from 'react-query';
import { useSearchParams } from 'react-router-dom';
const OAuth2Register = () => {
const [passwords, setPasswords] = useState({ password: "", checkPassword: ""});
const [ searchParams, setSearchParams] = useSearchParams();
const oauth2Register = useMutation(async (registerData) => {
const option = {
headers:{
registerToken: `Bearer ${registerToken}`
}
}
return await axios.post("http://localhost:8080/auth/oauth2/register",registerData,option);
});
const registerToken = searchParams.get("registerToken");
const email = searchParams.get("email");
const name = searchParams.get("name");
const passwordInputChangeHandle = (e) => {
const { name, value} = e.target;
setPasswords({...passwords,[name]: value});
}
const oauth2RegisterSubmitHandle = () => {
oauth2Register.mutate({
email,
name,
...passwords
});
}
return (
<div>
<input type="text" value={email} disabled = {true} />
<input type="text" value={name} disabled= {true} />
<input type="password" name='password' placeholder="비밀번호" onChange={passwordInputChangeHandle} />
<input type="password" name='checkPassword' placeholder="비밀번호확인" onChange={passwordInputChangeHandle} />
<button onClick={oauth2RegisterSubmitHandle}>가입하기</button>
</div>
);
};
export default OAuth2Register;
back
controller > AuthController
package com.study.oauth2.controller;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import com.study.oauth2.dto.auth.OAuth2RegisterReqDto;
import com.study.oauth2.security.jwt.JwtTokenProvider;
import lombok.RequiredArgsConstructor;
@RestController
@RequestMapping("/auth")
@RequiredArgsConstructor
public class AuthController {
private final JwtTokenProvider jwtTokenProvider;
@PostMapping("/oauth2/register")
public ResponseEntity<?> oauth2Register(
@RequestHeader(value="registerToken") String registerToken,
@RequestBody OAuth2RegisterReqDto oAuth2RegisterReqDto) {
boolean validated = jwtTokenProvider.validateToken(jwtTokenProvider.getToken(registerToken));
if(!validated) {
//토큰이 유효하지 않음
return ResponseEntity.badRequest().body("회원가입 요청 시간이 초과하였습니다.");
}
System.out.println(oAuth2RegisterReqDto);
return ResponseEntity.ok(null);
}
}
dto > auth > OAuth2RegisterReqDto
package com.study.oauth2.dto.auth;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import com.study.oauth2.entity.User;
import lombok.Data;
@Data
public class OAuth2RegisterReqDto {
private String email;
private String name;
private String password;
private String checkPassword;
private String provider;
public User toEntity() {
return User.builder()
.email(email)
.name(name)
.password(new BCryptPasswordEncoder().encode(password))
.provider(provider)
.build();
}
}
application.yml
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/book_management
username: root
password: root
security:
oauth2:
client:
registration:
google:
client-id: <your id>
client-secret: <your secret>
scope:
- email
- profile
kakao:
client-id: <your id>
client-secret: <your secret>
redirect-uri: http://localhost:8080/login/oauth2/code/kakao
authorization-grant-type: authorization_code
client-authentication-method: POST
client-name: Kakao
scope:
- profile
- account_email
naver:
client-id: <your id>
client-secret: <your secret>
redirect-uri: http://localhost:8080/login/oauth2/code/naver
authorization-grant-type: authorization_code
scope:
- name
- email
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
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
mybatis:
mapper-locations:
- /mappers/*.xml
jwt:
secret: uAdzVUhnjML7pCLQLDapBdNacinrqdRjbaqLD7sMUfe0ILk8KKqk5Xb0WncSuIre
Login.js(추가)
import React from 'react';
import { FcGoogle } from 'react-icons/fc';
import { useNavigate } from 'react-router-dom';
import { SiNaver } from 'react-icons/si';
const Login = () => {
const navigate = useNavigate();
const googleAuthClickHandle = () => {
window.location.href="http://localhost:8080/oauth2/authorization/google";
}
const naverAuthClickHandle = () => {
window.location.href="http://localhost:8080/oauth2/authorization/naver";
}
return (
<div>
<input type="text" placeholder='email' />
<input type="password" placeholder='password' />
<button>로그인</button>
<button onClick={googleAuthClickHandle}><FcGoogle /></button>
<button onClick={naverAuthClickHandle}><SiNaver/></button>
</div>
);
};
export default Login;
AuthService
/*
* 확정 로직 아래에서 확인
*/
OAuth2SuccessHandler
package com.study.oauth2.security;
import java.io.IOException;
import java.net.URLEncoder;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import com.study.oauth2.entity.User;
import com.study.oauth2.repository.UserRepository;
import com.study.oauth2.security.jwt.JwtTokenProvider;
import lombok.RequiredArgsConstructor;
@Component
@RequiredArgsConstructor
public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
private final UserRepository userRepository;
private final JwtTokenProvider jwtTokenProvider;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException, ServletException {
// 로그인이 성공적으로 이루어졌을때 8080 (Back) 으로 온 응답을 3000 (Front)으로 redirect
OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal();
String email = oAuth2User.getAttribute("email");
String provider = oAuth2User.getAttribute("provider");
User userEntity = userRepository.findUserByEmail(email);
if(userEntity == null) {
// 회원가입 실패
String registerToken = jwtTokenProvider.generateOAuth2RegisterToken(authentication);
String name = oAuth2User.getAttribute("name");
response
.sendRedirect(
"http://localhost:3000/auth/oauth2/register"
+"?registerToken=" + registerToken
+ "&email=" + email
+ "&name=" + URLEncoder.encode(name,"UTF-8")
+ "&provider=" + provider
);
}else {
// 회원가입 성공
if(StringUtils.hasText(userEntity.getProvider())) {
// 회원가입이 됐고, provider가 등록된 경우
if(!userEntity.getProvider().contains(provider)) {
// 하지만 로그인이된 oauth2 계정의 provider는 등록이 안된 경우
}
}else {
// 회원가입은 정상적으로 됐으나, provider가 null인 경우
response.sendRedirect("http://localhost:3000/auth/oauth2/merge"
+ "?provider=" + provider
+ "&email=" + email);
}
}
}
}
pages > OAuth2Merge > OAuth2Merge.js
/*
* 확정 로직 아래에서 확인
*/
AuthController
package com.study.oauth2.controller;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import com.study.oauth2.dto.auth.OAuth2ProviderMergeReqDto;
import com.study.oauth2.dto.auth.OAuth2RegisterReqDto;
import com.study.oauth2.security.jwt.JwtTokenProvider;
import com.study.oauth2.service.AuthService;
import lombok.RequiredArgsConstructor;
@RestController
@RequestMapping("/auth")
@RequiredArgsConstructor
public class AuthController {
private final JwtTokenProvider jwtTokenProvider;
private final AuthService authService;
@PostMapping("/oauth2/register")
public ResponseEntity<?> oauth2Register(
@RequestHeader(value="registerToken") String registerToken,
@RequestBody OAuth2RegisterReqDto oAuth2RegisterReqDto) {
boolean validated = jwtTokenProvider.validateToken(jwtTokenProvider.getToken(registerToken));
if(!validated) {
//토큰이 유효하지 않음
return ResponseEntity.badRequest().body("회원가입 요청 시간이 초과하였습니다.");
}
return ResponseEntity.ok(authService.oAuth2Register(oAuth2RegisterReqDto));
}
@PutMapping("/oauth2/merge")
public ResponseEntity<?> providerMerge(@RequestBody OAuth2ProviderMergeReqDto oAuth2ProviderMergeReqDto){
// 기존의 암호와 비교를 해야함
// DB에 암호가 들어있음
if(!authService.checkPassword(oAuth2ProviderMergeReqDto.getEmail(), oAuth2ProviderMergeReqDto.getPassword())) {
return ResponseEntity.badRequest().body("비밀번호가 일치하지 않습니다.");
}
return ResponseEntity.ok(authService.oAuth2ProviderMerge(oAuth2ProviderMergeReqDto));
}
}
dto > auth
OAuth2ProviderMergeReqDto
package com.study.oauth2.dto.auth;
import lombok.Data;
@Data
public class OAuth2ProviderMergeReqDto {
private String email;
private String password;
private String provider;
}
AuthService (추가)
package com.study.oauth2.service;
import java.util.Collections;
import java.util.Map;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
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.client.userinfo.OAuth2UserService;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.user.DefaultOAuth2User;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Service;
import com.study.oauth2.dto.auth.OAuth2RegisterReqDto;
import com.study.oauth2.entity.Authority;
import com.study.oauth2.entity.User;
import com.study.oauth2.repository.UserRepository;
import com.study.oauth2.security.OAuth2Attribute;
import lombok.RequiredArgsConstructor;
@Service
@RequiredArgsConstructor
public class AuthService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
private final UserRepository userRepository;
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
OAuth2UserService<OAuth2UserRequest, OAuth2User> oAuth2UserService = new DefaultOAuth2UserService();
OAuth2User oAuth2User = oAuth2UserService.loadUser(userRequest);
System.out.println(oAuth2User);
String registrationId = userRequest.getClientRegistration().getRegistrationId(); //Google (문자로 구글, 네이버 카카오를 들고옴)
OAuth2Attribute oAuth2Attribute = OAuth2Attribute.of(registrationId, oAuth2User.getAttributes());
Map<String, Object> Attributes = oAuth2Attribute.convertToMap();
// ArrayList<SimpleGrantedAuthority> authorities = new ArrayList<>();
// authorities.add(new SimpleGrantedAuthority("ROLE_USER"));
return new DefaultOAuth2User(Collections.singleton(new SimpleGrantedAuthority("ROLE_USER")), Attributes, "email");
}
public int oAuth2Register(OAuth2RegisterReqDto oAuth2RegisterReqDto) {
User userEntity = oAuth2RegisterReqDto.toEntity();
userRepository.saveUser(userEntity);
return userRepository.saveAuthority(
Authority.builder()
.userId(userEntity.getUserId())
.roleId(1)
.build()
);
}
// 암호 비교
// DI등록하려고 보니.. IOC에 등록이 안돼있음
public boolean checkPassword(String email, String password) {
User userEntity = userRepository.findUserByEmail(email);
BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
return passwordEncoder.matches(password, userEntity.getPassword());
}
}
SecurityConfig (Bean 등록)
package com.study.oauth2.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import com.study.oauth2.security.OAuth2SuccessHandler;
import com.study.oauth2.service.AuthService;
import lombok.RequiredArgsConstructor;
@EnableWebSecurity
@Configuration
@RequiredArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter {
private final AuthService authService;
private final OAuth2SuccessHandler oAuth2SuccessHandler;
@Bean
public BCryptPasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.httpBasic().disable();
http.formLogin().disable();
http.cors();
http.csrf().disable();
http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
http.authorizeRequests()
.antMatchers("/auth/**")
.permitAll()
.anyRequest()
.authenticated()
.and()
.oauth2Login()
.loginPage("http://localhost:3000/auth/login")
.successHandler(oAuth2SuccessHandler)
.userInfoEndpoint()
.userService(authService);
}
}
DB provider가 null 일 때
구글 로그인
네이버 로그인
비밀번호가 일치하지 않을 때
AuthService (통합 기능추가)
package com.study.oauth2.service;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
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.client.userinfo.OAuth2UserService;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.user.DefaultOAuth2User;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import com.study.oauth2.dto.auth.OAuth2ProviderMergeReqDto;
import com.study.oauth2.dto.auth.OAuth2RegisterReqDto;
import com.study.oauth2.entity.Authority;
import com.study.oauth2.entity.User;
import com.study.oauth2.repository.UserRepository;
import com.study.oauth2.security.OAuth2Attribute;
import lombok.RequiredArgsConstructor;
@Service
@RequiredArgsConstructor
public class AuthService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
private final UserRepository userRepository;
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
OAuth2UserService<OAuth2UserRequest, OAuth2User> oAuth2UserService = new DefaultOAuth2UserService();
OAuth2User oAuth2User = oAuth2UserService.loadUser(userRequest);
System.out.println(oAuth2User);
String registrationId = userRequest.getClientRegistration().getRegistrationId(); //Google (문자로 구글, 네이버 카카오를 들고옴)
OAuth2Attribute oAuth2Attribute = OAuth2Attribute.of(registrationId, oAuth2User.getAttributes());
Map<String, Object> Attributes = oAuth2Attribute.convertToMap();
// ArrayList<SimpleGrantedAuthority> authorities = new ArrayList<>();
// authorities.add(new SimpleGrantedAuthority("ROLE_USER"));
return new DefaultOAuth2User(Collections.singleton(new SimpleGrantedAuthority("ROLE_USER")), Attributes, "email");
}
public int oAuth2Register(OAuth2RegisterReqDto oAuth2RegisterReqDto) {
User userEntity = oAuth2RegisterReqDto.toEntity();
userRepository.saveUser(userEntity);
return userRepository.saveAuthority(
Authority.builder()
.userId(userEntity.getUserId())
.roleId(1)
.build()
);
}
// 암호 비교
// DI등록하려고 보니.. IOC에 등록이 안돼있음
public boolean checkPassword(String email, String password) {
User userEntity = userRepository.findUserByEmail(email);
BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
return passwordEncoder.matches(password, userEntity.getPassword());
}
public int oAuth2ProviderMerge(OAuth2ProviderMergeReqDto oAuth2ProviderMergeReqDto) {
User userEntity = userRepository.findUserByEmail(oAuth2ProviderMergeReqDto.getEmail());
String provider = oAuth2ProviderMergeReqDto.getProvider();
if(StringUtils.hasText(userEntity.getProvider())) {
// 문자가 있는경우
userEntity.setProvider(userEntity.getProvider() + "," + provider); //기존의 로그인 provider, + @
}else {
// 문자가 없는경우
userEntity.setProvider(provider); // provider
}
return userRepository.updateProvider(userEntity);
}
}
UserRepository (추가)
package com.study.oauth2.repository;
import org.apache.ibatis.annotations.Mapper;
import com.study.oauth2.entity.Authority;
import com.study.oauth2.entity.User;
@Mapper
public interface UserRepository {
// 이메일 중복확인
public User findUserByEmail(String email);
// 유저 등록
public int saveUser (User user);
public int saveAuthority(Authority authority);
public int updateProvider(User user);
}
UserMapper.xml (추가)
<update id="updateProvider" parameterType="com.study.oauth2.entity.User">
update user_tb
set
provider = #{provider}
where
user_id = #{userId}
</update>
onAuthenticationSuccess (추가)
package com.study.oauth2.security;
import java.io.IOException;
import java.net.URLEncoder;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import com.study.oauth2.entity.User;
import com.study.oauth2.repository.UserRepository;
import com.study.oauth2.security.jwt.JwtTokenProvider;
import lombok.RequiredArgsConstructor;
@Component
@RequiredArgsConstructor
public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
private final UserRepository userRepository;
private final JwtTokenProvider jwtTokenProvider;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException, ServletException {
// 로그인이 성공적으로 이루어졌을때 8080 (Back) 으로 온 응답을 3000 (Front)으로 redirect
OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal();
String email = oAuth2User.getAttribute("email");
String provider = oAuth2User.getAttribute("provider");
User userEntity = userRepository.findUserByEmail(email);
if(userEntity == null) {
// 회원가입 실패
String registerToken = jwtTokenProvider.generateOAuth2RegisterToken(authentication);
String name = oAuth2User.getAttribute("name");
response
.sendRedirect(
"http://localhost:3000/auth/oauth2/register"
+"?registerToken=" + registerToken
+ "&email=" + email
+ "&name=" + URLEncoder.encode(name,"UTF-8")
+ "&provider=" + provider
);
}else {
// 회원가입 성공
if(StringUtils.hasText(userEntity.getProvider())) {
// 회원가입이 됐고, provider가 등록된 경우
if(!userEntity.getProvider().contains(provider)) {
// 하지만 로그인이된 oauth2 계정의 provider는 등록이 안된 경우
response.sendRedirect("http://localhost:3000/auth/oauth2/merge"
+ "?provider=" + provider
+ "&email=" + email);
}
}else {
// 회원가입은 정상적으로 됐으나, provider가 null인 경우
response.sendRedirect("http://localhost:3000/auth/oauth2/merge"
+ "?provider=" + provider
+ "&email=" + email);
}
}
}
}
``
OAuth2Merge.js (추가)
import axios from 'axios';
import React, { useState } from 'react';
import { useMutation } from 'react-query';
import { useSearchParams } from 'react-router-dom';
const OAuth2Merge = () => {
const providerMerge = useMutation(async (mergeData) => {
try{
const response = await axios.put("http://localhost:8080/auth/oauth2/merge", mergeData);
return response;
}catch(error){
/**
* 비밀번호 틀렸을 때, 토큰이 만료됐을 때
*/
setErrorMsg(error.response.data);
return error;
}
},{
onSuccess: (response) => {
if(response.status ===200){
alert("계정 통합 완료!");
window.location.replace("/auth/login");
}
}
});
const [password, setPassword] = useState();
const [errorMsg, setErrorMsg] = useState("");
const[ searchParams, setSearchParams ] = useSearchParams();
const email = searchParams.get("email");
const provider = searchParams.get("provider");
const passwordChangeHandle = (e) =>{
setPassword(e.target.value);
}
const providerMergeSubmitHandle = () =>{
providerMerge.mutate({
email,
password,
provider
})
}
return (
<div>
<h1>{email}계정을 {provider} 계정과 통합하는 것에 동의 하십니까?</h1>
<input type="password" onChange={passwordChangeHandle} placeholder='기존 계정의 비밀번호를 입력하세요' />
<p>{errorMsg}</p>
<button onClick={providerMergeSubmitHandle}>동의</button>
<button>취소</button>
</div>
);
};
export default OAuth2Merge;
JwtTokenProvider (추가)
package com.study.oauth2.security.jwt;
import java.security.Key;
import java.util.Date;
import javax.management.RuntimeErrorException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import com.study.oauth2.security.PrincipalUser;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
@Component
public class JwtTokenProvider {
private final Key key;
public JwtTokenProvider(@Value("${jwt.secret}") String secretKey) {
key = Keys.hmacShaKeyFor(Decoders.BASE64.decode(secretKey));
}
public String generateAccessToken(Authentication authentication) {
String email = null;
if(authentication.getClass() == UserDetails.class) {
// PrincipalUser
PrincipalUser principalUser = (PrincipalUser) authentication.getPrincipal(); //downcasting
email = principalUser.getEmail();
}else {
// OAuth2User
OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal();
email = oAuth2User.getAttribute("email");
}
// 권한 설정
if(authentication.getAuthorities() == null) {
throw new RuntimeException("등록된 권한이 없습니다.");
}
StringBuilder roles = new StringBuilder();
authentication.getAuthorities().forEach(authority -> {
roles.append(authority.getAuthority() + ",");
});
roles.delete(roles.length() - 1, roles.length()); //권한 마지막 쉼표 제거
Date tokenExpiresDate = new Date(new Date().getTime() + (1000 * 60 * 60 *24));
return Jwts.builder()
.setSubject("AccessToken")
.claim("email", authentication)
.claim("auth", roles)
.setExpiration(tokenExpiresDate)
.signWith(key,SignatureAlgorithm.HS256)
.compact();
}
// jwt Token 생성 (회원가입 전용 토큰)
public String generateOAuth2RegisterToken (Authentication authentication) {
// 만료기간
Date tokenExpiresDate = new Date(new Date().getTime() + (1000*60*10));
OAuth2User oAuth2User = (OAuth2User)authentication.getPrincipal();
String email = oAuth2User.getAttribute("email");
return Jwts.builder()
.setSubject("OAuth2Register")
.claim("email", email)
.setExpiration(tokenExpiresDate)
.signWith(key,SignatureAlgorithm.HS256)
.compact();
}
public Boolean validateToken(String token) {
try {
Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token);
return true;
}catch (Exception e) {
}
return false;
}
public String getToken(String jwtToken) {
String type = "Bearer ";
if(StringUtils.hasText(jwtToken) && jwtToken.startsWith(type)) {
return jwtToken.substring(type.length());
}
return null;
}
}
onAuthenticationSuccess
package com.study.oauth2.security;
import java.io.IOException;
import java.net.URLEncoder;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import com.study.oauth2.entity.User;
import com.study.oauth2.repository.UserRepository;
import com.study.oauth2.security.jwt.JwtTokenProvider;
import lombok.RequiredArgsConstructor;
@Component
@RequiredArgsConstructor
public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
private final UserRepository userRepository;
private final JwtTokenProvider jwtTokenProvider;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException, ServletException {
// 로그인이 성공적으로 이루어졌을때 8080 (Back) 으로 온 응답을 3000 (Front)으로 redirect
OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal();
String email = oAuth2User.getAttribute("email");
String provider = oAuth2User.getAttribute("provider");
User userEntity = userRepository.findUserByEmail(email);
if(userEntity == null) {
// 회원가입 실패
String registerToken = jwtTokenProvider.generateOAuth2RegisterToken(authentication);
String name = oAuth2User.getAttribute("name");
response
.sendRedirect(
"http://localhost:3000/auth/oauth2/register"
+"?registerToken=" + registerToken
+ "&email=" + email
+ "&name=" + URLEncoder.encode(name,"UTF-8")
+ "&provider=" + provider
);
}else {
// 회원가입 성공
if(StringUtils.hasText(userEntity.getProvider())) {
if(!userEntity.getProvider().contains(provider)) {
// 하지만 로그인이된 oauth2 계정의 provider는 등록이 안된 경우
response.sendRedirect("http://localhost:3000/auth/oauth2/merge"
+ "?provider=" + provider
+ "&email=" + email);
}
// 회원가입이 됐고, provider가 등록된 경우
response.sendRedirect("http://localhost:3000/auth/oauth2/login"
+ "?accessToken=" + jwtTokenProvider.generateAccessToken(authentication));
}else {
// 회원가입은 정상적으로 됐으나, provider가 null인 경우
response.sendRedirect("http://localhost:3000/auth/oauth2/merge"
+ "?provider=" + provider
+ "&email=" + email);
}
}
}
}
Login > OAuth2Login.js
import React from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
const OAuth2Login = () => {
const navigate = useNavigate();
const [ searchParams, setSearchParams] = useSearchParams();
const accessToken = searchParams.get("accessToken");
// !! 해야 정상적으로 형변환이 이루어짐
if(!!accessToken){
localStorage.setItem("accessToken", accessToken);
window.location.replace("/");
}
return (
<></>
);
};
export default OAuth2Login;
store > atoms
AuthAtoms.js
import { atom } from "recoil";
// 전역상태
export const authenticationState = atom({
key:"authenticationState",
default : false
});
AuthController (추가)
@GetMapping("/authenticated")
public ResponseEntity<?> authenticated(@RequestHeader(value = "Authorization") String accessToken) {
return ResponseEntity.ok(jwtTokenProvider.validateToken(jwtTokenProvider.getToken(accessToken)));
}
components > auth
AuthRoute.js
import axios from 'axios';
import React from 'react';
import { useQuery } from 'react-query';
import { Navigate, useNavigate } from 'react-router-dom';
import { useRecoilState } from 'recoil';
import { authenticationState } from '../../store/atoms/AuthAtoms';
const AuthRoute = ({ path, element}) => {
const navigate = useNavigate();
const [ authState, setAuthState ] = useRecoilState(authenticationState);
const authenticated = useQuery(["authenticated"], async () => {
const option = {
headers: {
"Authorization": `Bearer ${localStorage.getItem("accessToken")}`
}
}
return await axios.get("http://localhost:8080/auth/authenticated", option);
},{
onSuccess: (response) => {
if(response.status === 200){
if(response.data){
setAuthState(true);
/**
* window.location.replace("/");
* replace 하는 순간 제랜더링 되면서 상태가 날라가는 현상 발생
*/
}
}
}
})
const permitAllPaths = ["/","/notice"];
const authenticatedPaths = ["/mypage","/user"];
const authPath ="/auth"
if(authenticated.isLoading) {
return <></>
}
if(authState && path.startsWith(authPath)) {
navigate("/");
}
if(!authState && authenticatedPaths.filter(authenticatedPath => path.startsWith(authenticatedPath)).length > 0){
navigate("/auth/login");
}
return element;
};
export default AuthRoute;
App.js 수정
import React from 'react';
import { Routes, Route } from "react-router-dom";
import NotFound from './pages/NotFound/NotFound';
import Login from './pages/Login/Login';
import OAuth2Register from './pages/Register/OAuth2Register';
import OAuth2Merge from './pages/OAuth2Merge/OAuth2Merge';
import Index from './pages/Index/Index';
import OAuth2Login from './pages/Login/OAuth2Login';
import AuthRoute from './components/auth/AuthRoute';
function App() {
return (
<>
<Routes>
<Route path='/' element={<AuthRoute path={"/"} element={<Index />}/>}/>
<Route path='/mypage' element={<AuthRoute path={"/mypage"} element={<Index />}/>}/>
<Route path='/auth/login' element={<AuthRoute path={"/auth/login"} element={<Login />}/>} />
<Route path='/auth/register' />
<Route path='/auth/oauth2/login' element={<AuthRoute path={"auth/oauth2/login"} element={<OAuth2Login />}/>} />
<Route path='/auth/oauth2/register' element={<AuthRoute path={"/auth/oauth2/register"} element={<OAuth2Register />}/>} />
<Route path='/auth/oauth2/merge' element={<AuthRoute path={"/auth/oauth2/merge"} element={<OAuth2Merge />}/>} />
<Route path='/*' element={<NotFound />} />
</Routes>
</>
);
}
export default App;
AccessToken이 있을 때
AccessToken이 없을 때
http://localhost:3000/mypage 접속을 누르면
http://localhost:3000/auth/login로 이동 (AccessToken이 없기때문)
SecurityConfig
package com.study.oauth2.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import com.study.oauth2.security.OAuth2SuccessHandler;
import com.study.oauth2.service.AuthService;
import lombok.RequiredArgsConstructor;
@EnableWebSecurity
@Configuration
@RequiredArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter {
private final AuthService authService;
private final OAuth2SuccessHandler oAuth2SuccessHandler;
@Bean
public BCryptPasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.httpBasic().disable();
http.formLogin().disable();
http.cors();
http.csrf().disable();
http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
http.authorizeRequests()
.antMatchers("/auth/**")
.permitAll()
.anyRequest()
.authenticated()
.and()
.oauth2Login()
.loginPage("http://localhost:3000/auth/login")
.successHandler(oAuth2SuccessHandler)
.userInfoEndpoint()
.userService(authService);
}
}
WebMvcConfig
package com.study.oauth2.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOrigins("*")
.allowedMethods("*");
}
}
AuthController
package com.study.oauth2.controller;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import com.study.oauth2.dto.auth.OAuth2ProviderMergeReqDto;
import com.study.oauth2.dto.auth.OAuth2RegisterReqDto;
import com.study.oauth2.security.jwt.JwtTokenProvider;
import com.study.oauth2.service.AuthService;
import lombok.RequiredArgsConstructor;
@RestController
@RequestMapping("/auth")
@RequiredArgsConstructor
public class AuthController {
private final JwtTokenProvider jwtTokenProvider;
private final AuthService authService;
@PostMapping("/oauth2/register")
public ResponseEntity<?> oauth2Register(
@RequestHeader(value="registerToken") String registerToken,
@RequestBody OAuth2RegisterReqDto oAuth2RegisterReqDto) {
boolean validated = jwtTokenProvider.validateToken(jwtTokenProvider.getToken(registerToken));
if(!validated) {
//토큰이 유효하지 않음
return ResponseEntity.badRequest().body("회원가입 요청 시간이 초과하였습니다.");
}
return ResponseEntity.ok(authService.oAuth2Register(oAuth2RegisterReqDto));
}
@PutMapping("/oauth2/merge")
public ResponseEntity<?> providerMerge(@RequestBody OAuth2ProviderMergeReqDto oAuth2ProviderMergeReqDto){
// 기존의 암호와 비교를 해야함
// DB에 암호가 들어있음
if(!authService.checkPassword(oAuth2ProviderMergeReqDto.getEmail(), oAuth2ProviderMergeReqDto.getPassword())) {
return ResponseEntity.badRequest().body("비밀번호가 일치하지 않습니다.");
}
return ResponseEntity.ok(authService.oAuth2ProviderMerge(oAuth2ProviderMergeReqDto));
}
@GetMapping("/authenticated")
public ResponseEntity<?> authenticated(@RequestHeader(value = "Authorization") String accessToken) {
return ResponseEntity.ok(jwtTokenProvider.validateToken(jwtTokenProvider.getToken(accessToken)));
}
}
OAuth2ProviderMergeReqDto
package com.study.oauth2.dto.auth;
import lombok.Data;
@Data
public class OAuth2ProviderMergeReqDto {
private String email;
private String password;
private String provider;
}
OAuth2RegisterReqDto
package com.study.oauth2.dto.auth;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import com.study.oauth2.entity.User;
import lombok.Data;
@Data
public class OAuth2RegisterReqDto {
private String email;
private String name;
private String password;
private String checkPassword;
private String provider;
public User toEntity() {
return User.builder()
.email(email)
.name(name)
.password(new BCryptPasswordEncoder().encode(password))
.provider(provider)
.build();
}
}
Authority
package com.study.oauth2.entity;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@AllArgsConstructor
@Builder
@NoArgsConstructor
@Data
public class Authority {
private int authorityId;
private int userId;
private int roleId;
private Role role;
}
Role
package com.study.oauth2.entity;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Builder
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Role {
private int roleId;
private String roleName;
}
User
package com.study.oauth2.entity;
import java.util.ArrayList;
import java.util.List;
import com.study.oauth2.security.PrincipalUser;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Data
public class User {
private int userId;
private String email;
private String password;
private String name;
private String provider;
private List<Authority> authorities;
public PrincipalUser toPrincipal() {
List<String> roles = new ArrayList<>();
authorities.forEach(authority ->{
roles.add(authority.getRole().getRoleName());
});
return PrincipalUser.builder()
.userId(userId)
.email(email)
.password(password)
.authorities(authorities)
.build();
}
}
UserRepository
package com.study.oauth2.repository;
import org.apache.ibatis.annotations.Mapper;
import com.study.oauth2.entity.Authority;
import com.study.oauth2.entity.User;
@Mapper
public interface UserRepository {
// 이메일 중복확인
public User findUserByEmail(String email);
// 유저 등록
public int saveUser (User user);
public int saveAuthority(Authority authority);
public int updateProvider(User user);
}
OAuth2Attribute
package com.study.oauth2.security;
import java.util.HashMap;
import java.util.Map;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.ToString;
@ToString
@Builder(access = AccessLevel.PRIVATE) //option (NoArg에 private 걸어두는 방법)
@Getter
public class OAuth2Attribute {
private Map<String, Object> attributes;
private String email;
private String name;
private String provider;
public static OAuth2Attribute of(String provider, Map<String, Object> attributes) {
switch (provider) {
case "google":
return ofGoogle(provider,attributes);
case "kakao":
return ofKakao(provider, attributes);
case "naver":
return ofNaver(provider, attributes);
default:
throw new RuntimeException();
}
}
private static OAuth2Attribute ofGoogle(String provider, Map<String, Object> attributes) {
return OAuth2Attribute.builder()
.name((String) attributes.get("name"))
.email((String) attributes.get("email"))
.provider(provider)
.attributes(attributes)
.build();
}
private static OAuth2Attribute ofKakao(String provider, Map<String, Object> attributes) {
Map<String, Object> kakaoAccount = (Map<String, Object>) attributes.get("kakao_account");
Map<String, Object> kakaoProfile = (Map<String, Object>) kakaoAccount.get("profile");
return OAuth2Attribute.builder()
.name((String) kakaoProfile.get("nickname"))
.email((String) kakaoAccount.get("email"))
.provider(provider)
.attributes(kakaoAccount)
.build();
}
private static OAuth2Attribute ofNaver(String provider, Map<String, Object> attributes) {
Map<String, Object> response = (Map<String, Object>) attributes.get("response");
return OAuth2Attribute.builder()
.name((String) response.get("name"))
.email((String) response.get("email"))
.provider(provider)
.attributes(response)
.build();
}
public Map<String, Object> convertToMap() {
Map<String, Object> map = new HashMap<>();
map.put("name", name);
map.put("email", email);
map.put("provider", provider);
return map;
}
}
OAuth2SuccessHandler
package com.study.oauth2.security;
import java.io.IOException;
import java.net.URLEncoder;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import com.study.oauth2.entity.User;
import com.study.oauth2.repository.UserRepository;
import com.study.oauth2.security.jwt.JwtTokenProvider;
import lombok.RequiredArgsConstructor;
@Component
@RequiredArgsConstructor
public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
private final UserRepository userRepository;
private final JwtTokenProvider jwtTokenProvider;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException, ServletException {
// 로그인이 성공적으로 이루어졌을때 8080 (Back) 으로 온 응답을 3000 (Front)으로 redirect
OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal();
String email = oAuth2User.getAttribute("email");
String provider = oAuth2User.getAttribute("provider");
User userEntity = userRepository.findUserByEmail(email);
if(userEntity == null) {
// 회원가입 실패
String registerToken = jwtTokenProvider.generateOAuth2RegisterToken(authentication);
String name = oAuth2User.getAttribute("name");
response
.sendRedirect(
"http://localhost:3000/auth/oauth2/register"
+"?registerToken=" + registerToken
+ "&email=" + email
+ "&name=" + URLEncoder.encode(name,"UTF-8")
+ "&provider=" + provider
);
}else {
// 회원가입 성공
if(StringUtils.hasText(userEntity.getProvider())) {
if(!userEntity.getProvider().contains(provider)) {
// 하지만 로그인이된 oauth2 계정의 provider는 등록이 안된 경우
response.sendRedirect("http://localhost:3000/auth/oauth2/merge"
+ "?provider=" + provider
+ "&email=" + email);
return;
}
// 회원가입이 됐고, provider가 등록된 경우
response.sendRedirect("http://localhost:3000/auth/oauth2/login"
+ "?accessToken=" + jwtTokenProvider.generateAccessToken(authentication));
}else {
// 회원가입은 정상적으로 됐으나, provider가 null인 경우
response.sendRedirect("http://localhost:3000/auth/oauth2/merge"
+ "?provider=" + provider
+ "&email=" + email);
}
}
}
}
PrincipalUser
package com.study.oauth2.security;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import com.study.oauth2.entity.Authority;
import lombok.Builder;
import lombok.Getter;
@Builder
@Getter
public class PrincipalUser implements UserDetails {
private static final long serialVersionUID = 3893676052625302075L;
private int userId;
private String email;
private String password;
private List<Authority> authorities;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
List<SimpleGrantedAuthority> authorities= new ArrayList<>();
this.authorities.forEach(authority -> {
authorities.add(new SimpleGrantedAuthority(authority.getRole().getRoleName()));
});
return authorities;
}
@Override
public String getPassword() {
return password; //암호화 된 비밀번호
}
@Override
public String getUsername() {
return email;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
JwtTokenProvider
package com.study.oauth2.security.jwt;
import java.security.Key;
import java.util.Date;
import javax.management.RuntimeErrorException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import com.study.oauth2.security.PrincipalUser;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
@Component
public class JwtTokenProvider {
private final Key key;
public JwtTokenProvider(@Value("${jwt.secret}") String secretKey) {
key = Keys.hmacShaKeyFor(Decoders.BASE64.decode(secretKey));
}
public String generateAccessToken(Authentication authentication) {
String email = null;
if(authentication.getClass() == UserDetails.class) {
// PrincipalUser
PrincipalUser principalUser = (PrincipalUser) authentication.getPrincipal(); //downcasting
email = principalUser.getEmail();
}else {
// OAuth2User
OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal();
email = oAuth2User.getAttribute("email");
}
// 권한 설정
if(authentication.getAuthorities() == null) {
throw new RuntimeException("등록된 권한이 없습니다.");
}
StringBuilder roles = new StringBuilder();
authentication.getAuthorities().forEach(authority -> {
roles.append(authority.getAuthority() + ",");
});
roles.delete(roles.length() - 1, roles.length()); //권한 마지막 쉼표 제거
Date tokenExpiresDate = new Date(new Date().getTime() + (1000 * 60 * 60 *24));
return Jwts.builder()
.setSubject("AccessToken")
.claim("email", authentication)
.claim("auth", roles)
.setExpiration(tokenExpiresDate)
.signWith(key,SignatureAlgorithm.HS256)
.compact();
}
// jwt Token 생성 (회원가입 전용 토큰)
public String generateOAuth2RegisterToken (Authentication authentication) {
// 만료기간
Date tokenExpiresDate = new Date(new Date().getTime() + (1000*60*10));
OAuth2User oAuth2User = (OAuth2User)authentication.getPrincipal();
String email = oAuth2User.getAttribute("email");
return Jwts.builder()
.setSubject("OAuth2Register")
.claim("email", email)
.setExpiration(tokenExpiresDate)
.signWith(key,SignatureAlgorithm.HS256)
.compact();
}
public Boolean validateToken(String token) {
try {
Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token);
return true;
}catch (Exception e) {
}
return false;
}
public String getToken(String jwtToken) {
String type = "Bearer ";
if(StringUtils.hasText(jwtToken) && jwtToken.startsWith(type)) {
return jwtToken.substring(type.length());
}
return null;
}
}
AuthService
package com.study.oauth2.service;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
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.client.userinfo.OAuth2UserService;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.user.DefaultOAuth2User;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import com.study.oauth2.dto.auth.OAuth2ProviderMergeReqDto;
import com.study.oauth2.dto.auth.OAuth2RegisterReqDto;
import com.study.oauth2.entity.Authority;
import com.study.oauth2.entity.User;
import com.study.oauth2.repository.UserRepository;
import com.study.oauth2.security.OAuth2Attribute;
import lombok.RequiredArgsConstructor;
@Service
@RequiredArgsConstructor
public class AuthService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
private final UserRepository userRepository;
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
OAuth2UserService<OAuth2UserRequest, OAuth2User> oAuth2UserService = new DefaultOAuth2UserService();
OAuth2User oAuth2User = oAuth2UserService.loadUser(userRequest);
System.out.println(oAuth2User);
String registrationId = userRequest.getClientRegistration().getRegistrationId(); //Google (문자로 구글, 네이버 카카오를 들고옴)
OAuth2Attribute oAuth2Attribute = OAuth2Attribute.of(registrationId, oAuth2User.getAttributes());
Map<String, Object> Attributes = oAuth2Attribute.convertToMap();
// ArrayList<SimpleGrantedAuthority> authorities = new ArrayList<>();
// authorities.add(new SimpleGrantedAuthority("ROLE_USER"));
return new DefaultOAuth2User(Collections.singleton(new SimpleGrantedAuthority("ROLE_USER")), Attributes, "email");
}
public int oAuth2Register(OAuth2RegisterReqDto oAuth2RegisterReqDto) {
User userEntity = oAuth2RegisterReqDto.toEntity();
userRepository.saveUser(userEntity);
return userRepository.saveAuthority(
Authority.builder()
.userId(userEntity.getUserId())
.roleId(1)
.build()
);
}
// 암호 비교
// DI등록하려고 보니.. IOC에 등록이 안돼있음
public boolean checkPassword(String email, String password) {
User userEntity = userRepository.findUserByEmail(email);
BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
return passwordEncoder.matches(password, userEntity.getPassword());
}
public int oAuth2ProviderMerge(OAuth2ProviderMergeReqDto oAuth2ProviderMergeReqDto) {
User userEntity = userRepository.findUserByEmail(oAuth2ProviderMergeReqDto.getEmail());
String provider = oAuth2ProviderMergeReqDto.getProvider();
if(StringUtils.hasText(userEntity.getProvider())) {
// 문자가 있는경우
userEntity.setProvider(userEntity.getProvider() + "," + provider); //기존의 로그인 provider, + @
}else {
// 문자가 없는경우
userEntity.setProvider(provider); // provider
}
return userRepository.updateProvider(userEntity);
}
}
Oauth2Application
package com.study.oauth2;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class Oauth2Application {
public static void main(String[] args) {
SpringApplication.run(Oauth2Application.class, args);
}
}
application.yml
/*
* 위 내용 참고
*/
UserMapper.xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.study.oauth2.repository.UserRepository">
<resultMap type="com.study.oauth2.entity.User" id="userMap">
<id property="userId" column="user_id"/>
<result property="email" column="email"/>
<result property="password" column="password"/>
<result property="name" column="name"/>
<result property="provider" column="provider"/>
<collection property="authorities" javaType="list" resultMap="authorityMap" />
</resultMap>
<resultMap type="com.study.oauth2.entity.Authority" id="authorityMap">
<id property="authorityId" column="authority_id"/>
<result property="userId" column="user_id"/>
<result property="roleId" column="role_id"/>
<association property="role" resultMap="RoleMap"></association>
</resultMap>
<resultMap type="com.study.oauth2.entity.Role" id="RoleMap">
<id property="roleId" column="role_id"/>
<result property="roleName" column="role_name"/>
</resultMap>
<select id="findUserByEmail" resultMap="userMap">
select
ut.user_id,
ut.email,
ut.password,
ut.name,
ut.provider,
at.authority_id,
at.user_id,
at.role_id,
rt.role_id,
rt.role_name
from
user_tb ut
left outer join authority_tb at on(at.user_id = ut.user_id)
left outer join role_tb rt on(rt.role_id = at.role_id)
where
ut.email = #{email}
</select>
<insert id="saveUser"
parameterType="com.study.oauth2.entity.User"
useGeneratedKeys="true"
keyProperty="userId">
insert into user_tb
values (0, #{email},#{password},#{name},#{provider})
</insert>
<insert id="saveAuthority" parameterType="com.study.oauth2.entity.Authority">
insert into authority_tb
values (0, #{userId}, #{roleId})
</insert>
<update id="updateProvider" parameterType="com.study.oauth2.entity.User">
update user_tb
set
provider = #{provider}
where
user_id = #{userId}
</update>
</mapper>
Login.js
import React from 'react';
import { FcGoogle } from 'react-icons/fc';
import { useNavigate } from 'react-router-dom';
import { SiNaver } from 'react-icons/si';
const Login = () => {
const navigate = useNavigate();
const googleAuthClickHandle = () => {
window.location.href="http://localhost:8080/oauth2/authorization/google";
}
const naverAuthClickHandle = () => {
window.location.href="http://localhost:8080/oauth2/authorization/naver";
}
return (
<div>
<input type="text" placeholder='email' />
<input type="password" placeholder='password' />
<button>로그인</button>
<button onClick={googleAuthClickHandle}><FcGoogle /></button>
<button onClick={naverAuthClickHandle}><SiNaver/></button>
</div>
);
};
export default Login;
OAuth2Login.js
import React from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
const OAuth2Login = () => {
const navigate = useNavigate();
const [ searchParams, setSearchParams] = useSearchParams();
const accessToken = searchParams.get("accessToken");
// !! 해야 정상적으로 형변환이 이루어짐
if(!!accessToken){
localStorage.setItem("accessToken", accessToken);
window.location.replace("/");
}
return (
<></>
);
};
export default OAuth2Login;
OAuth2Register.js
import axios from 'axios';
import React, { useState } from 'react';
import { useMutation } from 'react-query';
import { useNavigate, useSearchParams } from 'react-router-dom';
const OAuth2Register = () => {
const navigate = useNavigate();
const [passwords, setPasswords] = useState({ password: "", checkPassword: ""});
const oauth2Register = useMutation(async (registerData) => {
const option = {
headers:{
registerToken: `Bearer ${registerToken}`
}
}
try{
const response = await axios.post("http://localhost:8080/auth/oauth2/register", registerData, option);
return response;
} catch (error) {
alert("페이지가 만료되었습니다.");
window.location.replace("/auth/login");
return error;
}
},{
onSuccess: (response) => {
if(response.status ===200){
alert("회원가입 완료.");
window.location.replace("/auth/login");
}
}
});
const [ searchParams, setSearchParams] = useSearchParams();
const registerToken = searchParams.get("registerToken");
const email = searchParams.get("email");
const name = searchParams.get("name");
const provider = searchParams.get("provider");
const passwordInputChangeHandle = (e) => {
const { name, value} = e.target;
setPasswords({...passwords,[name]: value});
}
const oauth2RegisterSubmitHandle = () => {
oauth2Register.mutate({
email,
name,
...passwords,
provider
});
}
return (
<div>
<input type="text" value={email} disabled = {true} />
<input type="text" value={name} disabled= {true} />
<input type="password" name='password' placeholder="비밀번호" onChange={passwordInputChangeHandle} />
<input type="password" name='checkPassword' placeholder="비밀번호확인" onChange={passwordInputChangeHandle} />
<button onClick={oauth2RegisterSubmitHandle}>가입하기</button>
</div>
);
};
export default OAuth2Register;
OAuth2Merge.js
import axios from 'axios';
import React, { useState } from 'react';
import { useMutation } from 'react-query';
import { useSearchParams } from 'react-router-dom';
const OAuth2Merge = () => {
const providerMerge = useMutation(async (mergeData) => {
try{
const response = await axios.put("http://localhost:8080/auth/oauth2/merge", mergeData);
return response;
}catch(error){
/**
* 비밀번호 틀렸을 때, 토큰이 만료됐을 때
*/
setErrorMsg(error.response.data);
return error;
}
},{
onSuccess: (response) => {
if(response.status ===200){
alert("계정 통합 완료!");
window.location.replace("/auth/login");
}
}
});
const [password, setPassword] = useState();
const [errorMsg, setErrorMsg] = useState("");
const[ searchParams, setSearchParams ] = useSearchParams();
const email = searchParams.get("email");
const provider = searchParams.get("provider");
const passwordChangeHandle = (e) =>{
setPassword(e.target.value);
}
const providerMergeSubmitHandle = () =>{
providerMerge.mutate({
email,
password,
provider
})
}
return (
<div>
<h1>{email}계정을 {provider} 계정과 통합하는 것에 동의 하십니까?</h1>
<input type="password" onChange={passwordChangeHandle} placeholder='기존 계정의 비밀번호를 입력하세요' />
<p>{errorMsg}</p>
<button onClick={providerMergeSubmitHandle}>동의</button>
<button>취소</button>
</div>
);
};
export default OAuth2Merge;
AuthAtoms.js
import { atom } from "recoil";
// 전역상태
export const authenticationState = atom({
key:"authenticationState",
default : false
});
AuthRoute.js
import axios from 'axios';
import React from 'react';
import { useQuery } from 'react-query';
import { Navigate, useNavigate } from 'react-router-dom';
import { useRecoilState } from 'recoil';
import { authenticationState } from '../../store/atoms/AuthAtoms';
const AuthRoute = ({ path, element}) => {
const navigate = useNavigate();
const [ authState, setAuthState ] = useRecoilState(authenticationState);
const authenticated = useQuery(["authenticated"], async () => {
const option = {
headers: {
"Authorization": `Bearer ${localStorage.getItem("accessToken")}`
}
}
return await axios.get("http://localhost:8080/auth/authenticated", option);
},{
onSuccess: (response) => {
if(response.status === 200){
if(response.data){
setAuthState(true);
/**
* window.location.replace("/");
* replace 하는 순간 제랜더링 되면서 상태가 날라가는 현상 발생
*/
}
}
}
})
const permitAllPaths = ["/","/notice"];
const authenticatedPaths = ["/mypage","/user"];
const authPath ="/auth"
if(authenticated.isLoading) {
return <></>
}
if(authState && path.startsWith(authPath)) {
navigate("/");
}
if(!authState && authenticatedPaths.filter(authenticatedPath => path.startsWith(authenticatedPath)).length > 0){
navigate("/auth/login");
}
return element;
};
export default AuthRoute;
Index.js
import React from 'react';
import { useRecoilState } from 'recoil';
import { authenticationState } from '../../store/atoms/AuthAtoms';
const Index = () => {
const [ authState, setAuthState ] = useRecoilState(authenticationState);
return (
<div>
{authState ? "인증됨" : "인증안됨"}
</div>
);
};
export default Index;
NotFound.js
import React from 'react';
const NotFound = () => {
return (
<div>
<h1>페이지를 찾을 수 없습니다.</h1>
</div>
);
};
export default NotFound;
App.js
import React from 'react';
import { Routes, Route } from "react-router-dom";
import NotFound from './pages/NotFound/NotFound';
import Login from './pages/Login/Login';
import OAuth2Register from './pages/Register/OAuth2Register';
import OAuth2Merge from './pages/OAuth2Merge/OAuth2Merge';
import Index from './pages/Index/Index';
import OAuth2Login from './pages/Login/OAuth2Login';
import AuthRoute from './components/auth/AuthRoute';
function App() {
return (
<>
<Routes>
<Route path='/' element={<AuthRoute path={"/"} element={<Index />}/>}/>
<Route path='/mypage' element={<AuthRoute path={"/mypage"} element={<Index />}/>}/>
<Route path='/auth/login' element={<AuthRoute path={"/auth/login"} element={<Login />}/>} />
<Route path='/auth/register' />
<Route path='/auth/oauth2/login' element={<AuthRoute path={"auth/oauth2/login"} element={<OAuth2Login />}/>} />
<Route path='/auth/oauth2/register' element={<AuthRoute path={"/auth/oauth2/register"} element={<OAuth2Register />}/>} />
<Route path='/auth/oauth2/merge' element={<AuthRoute path={"/auth/oauth2/merge"} element={<OAuth2Merge />}/>} />
<Route path='/*' element={<NotFound />} />
</Routes>
</>
);
}
export default App;