프로젝트를 진행하면서 기존에는 아이디와 비밀번호 기반의 인증 방식을 사용했지만, 이번에는 시간을 내어 JWT와 OAuth2를 활용한 소셜 로그인 기능을 구현해 보았습니다.
프론트엔드 개발은 Vue.js를 사용했고, 백엔드는 JPA를 기반으로 구성했습니다. Vue.js는 호돌맨 강의를 들으며 개인 프로젝트를 진행해 온 경험이 있어 자연스럽게 선택하게 되었습니다.
이번 게시물에서는 Spring Security를 이용해 Google과 GitHub 로그인을 구현하는 방법을 공유하려고 합니다. 프로젝트에서 JWT와 OAuth2를 결합하여 인증을 처리했으며, 이를 통해 RESTful 서비스에 적합한 무상태(stateless) 인증 구조를 만들었습니다.
구글과 깃허브 key를 가져오기 사전작업이 필요합니다.
Google Oauth2
Github Oauth2
여기 나와있는 식으로 key값을 사이트가서 등록한 후 key값을 가져온 후 application.properties에 등록해줍니다.
spring.security.oauth2.client.registration.github.client-id=${GITHUB_CLIENT_ID:...}
spring.security.oauth2.client.registration.github.client-secret=${GITHUB_CLIENT_SECRET:...}
spring.security.oauth2.client.registration.google.client-id=${GOOGLE_CLIENT_ID:...}
spring.security.oauth2.client.registration.google.client-secret=${GOOGLE_CLIENT_SECRET:...}
본격적인 개발에 들어가기 전에 Google, GitHub, Facebook, Okta와 같은 일반적인 OAuth2 공급자에 대한 ClientRegistration 설정을 쉽게 구성하기 위해 제공되는 유틸리티인 CommonOAuth2Provider의 열거형 클래스를 살펴보고 가겠습니다.
public enum CommonOAuth2Provider {
GOOGLE {
public ClientRegistration.Builder getBuilder(String registrationId) {
ClientRegistration.Builder builder = this.getBuilder(registrationId, ClientAuthenticationMethod.CLIENT_SECRET_BASIC, "{baseUrl}/{action}/oauth2/code/{registrationId}");
builder.scope(new String[]{"openid", "profile", "email"});
builder.authorizationUri("https://accounts.google.com/o/oauth2/v2/auth");
builder.tokenUri("https://www.googleapis.com/oauth2/v4/token");
builder.jwkSetUri("https://www.googleapis.com/oauth2/v3/certs");
builder.issuerUri("https://accounts.google.com");
builder.userInfoUri("https://www.googleapis.com/oauth2/v3/userinfo");
builder.userNameAttributeName("sub");
builder.clientName("Google");
return builder;
}
},
GITHUB {
public ClientRegistration.Builder getBuilder(String registrationId) {
ClientRegistration.Builder builder = this.getBuilder(registrationId, ClientAuthenticationMethod.CLIENT_SECRET_BASIC, "{baseUrl}/{action}/oauth2/code/{registrationId}");
builder.scope(new String[]{"read:user"});
builder.authorizationUri("https://github.com/login/oauth/authorize");
builder.tokenUri("https://github.com/login/oauth/access_token");
builder.userInfoUri("https://api.github.com/user");
builder.userNameAttributeName("id");
builder.clientName("GitHub");
return builder;
}
},
FACEBOOK {
public ClientRegistration.Builder getBuilder(String registrationId) {
ClientRegistration.Builder builder = this.getBuilder(registrationId, ClientAuthenticationMethod.CLIENT_SECRET_POST, "{baseUrl}/{action}/oauth2/code/{registrationId}");
builder.scope(new String[]{"public_profile", "email"});
builder.authorizationUri("https://www.facebook.com/v2.8/dialog/oauth");
builder.tokenUri("https://graph.facebook.com/v2.8/oauth/access_token");
builder.userInfoUri("https://graph.facebook.com/me?fields=id,name,email");
builder.userNameAttributeName("id");
builder.clientName("Facebook");
return builder;
}
},
OKTA {
public ClientRegistration.Builder getBuilder(String registrationId) {
ClientRegistration.Builder builder = this.getBuilder(registrationId, ClientAuthenticationMethod.CLIENT_SECRET_BASIC, "{baseUrl}/{action}/oauth2/code/{registrationId}");
builder.scope(new String[]{"openid", "profile", "email"});
builder.userNameAttributeName("sub");
builder.clientName("Okta");
return builder;
}
};
private static final String DEFAULT_REDIRECT_URL = "{baseUrl}/{action}/oauth2/code/{registrationId}";
private CommonOAuth2Provider() {
}
protected final ClientRegistration.Builder getBuilder(String registrationId, ClientAuthenticationMethod method, String redirectUri) {
ClientRegistration.Builder builder = ClientRegistration.withRegistrationId(registrationId);
builder.clientAuthenticationMethod(method);
builder.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE);
builder.redirectUri(redirectUri);
return builder;
}
public abstract ClientRegistration.Builder getBuilder(String registrationId);
}
이 클래스의 역할은 Spring Security에서 OAuth2 공급자별로 자주 사용되는 설정을 미리 정의한 것으로, Google, GitHub, Facebook, Okta 등의 OAuth2 클라이언트 등록에 필요한 기본 정보(Authorization URI, Token URI, Scope 등)를 제공하여 개발자가 수작업으로 설정하지 않도록 도와줍니다.
위 코드는 ClientRegistration.Builder 객체를 반환하며, 이를 통해 Spring Security OAuth2 클라이언트 등록에 필요한 기본 설정을 제공합니다.
이를 활용하면 복잡한 설정 없이 OAuth2 클라이언트를 Spring Security에 쉽게 통합할 수 있습니다.
즉, 쉽게 말해 커스텀 하지 않고 가져다 쓰면 됩니다.
추가적으로 저는 oauth2 기본 로그인으로 구현하여서 커스텀 하실 분을 따로 고치시면 될 것 같습니다.
먼저 백엔드 코드부터 살펴보겠습니다.
256비트(32바이트) 비밀키를 생성하여 application.properties
에 추가할 값을 출력합니다.
public class SecretKeyGenerator {
public static void main(String[] args) {
SecureRandom random = new SecureRandom();
byte[] key = new byte[32]; // 256-bit key
random.nextBytes(key);
String secretKey = Base64.getUrlEncoder().withoutPadding().encodeToString(key);
System.out.println("Secret key 생성 : " + secretKey);
}
}
생성 후 빌드 한 뒤 찍히는 값을
jwt.secret:${SECRET_KEY:R~-}
application.properties에 넣어줍니다.
application.properties
에서 비밀키를 가져와 JwtUtil
에 주입합니다.
@Configuration
public class JwtConfig {
@Value("${jwt.secret}")
private String secretKey;
@Bean
public JwtUtil jwtUtil() {
return new JwtUtil(secretKey);
}
}
JWT를 생성하고 서명하며, 클레임 및 만료 시간을 설정합니다.
public class JwtUtil {
private final String secretKey;
private static final long EXPIRATION_TIME = 86400000; // 1일 (밀리초)
public JwtUtil(String secretKey) {
this.secretKey = secretKey;
}
public String generateToken(String subject, Map<String, Object> claims) {
Key signingKey = new SecretKeySpec(secretKey.getBytes(), SignatureAlgorithm.HS256.getJcaName());
return Jwts.builder()
.setSubject(subject)
.setClaims(claims)
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + EXPIRATION_TIME))
.signWith(signingKey, SignatureAlgorithm.HS256)
.compact();
}
}
OAuth2 인증 성공 시 사용자 정보를 기반으로 JWT를 생성하여 프론트엔드로 전달합니다.
@Component
public class JwtAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
private final JwtUtil jwtUtil;
public JwtAuthenticationSuccessHandler(JwtUtil jwtUtil) {
this.jwtUtil = jwtUtil;
}
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException {
var user = (OAuth2User) authentication.getPrincipal();
String email = user.getAttribute("email");
Map<String, Object> claims = new HashMap<>();
claims.put("name", user.getAttribute("name"));
claims.put("email", email);
String jwt = jwtUtil.generateToken(email, claims);
response.sendRedirect("http://localhost:8081/oauth2/redirect?token=" + jwt);
}
}
요청 시 JWT를 검증하여 인증 정보를 Spring Security의 SecurityContextHolder
에 설정합니다.
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Value("${jwt.secret}")
private String secretKey;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
String authHeader = request.getHeader("Authorization");
if (authHeader != null && authHeader.startsWith("Bearer ")) {
String token = authHeader.substring(7);
try {
Claims claims = Jwts.parserBuilder()
.setSigningKey(secretKey.getBytes())
.build()
.parseClaimsJws(token)
.getBody();
String username = claims.getSubject();
User user = new User(username, "", new ArrayList<>());
var auth = new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(auth);
} catch (Exception e) {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Invalid JWT token");
return;
}
}
chain.doFilter(request, response);
}
}
Spring Security 설정으로 OAuth2 및 JWT 인증, 세션 비활성화, CORS를 설정합니다.
@Configuration
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
private final JwtAuthenticationSuccessHandler successHandler;
public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter, JwtAuthenticationSuccessHandler successHandler) {
this.jwtAuthenticationFilter = jwtAuthenticationFilter;
this.successHandler = successHandler;
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(auth -> auth
.requestMatchers("/oauth2/**", "/login/**", "/").permitAll()
.anyRequest().authenticated()
)
.oauth2Login(oauth2 -> oauth2.successHandler(successHandler))
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
return http.build();
}
}
Vue.js와 Spring Boot 간의 CORS 문제를 해결합니다.
@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOrigins("http://localhost:8081")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true);
}
}
이제 백엔드 부분은 끝이 났습니다. vue.js 코드를 살펴보겠습니다!
src/
├── api/axios.js # Axios 설정 파일 (백엔드와의 통신)
├── App.vue # 애플리케이션의 메인 컴포넌트
├── components/
│ └── HelloWorld.vue # 로그인 성공 후 JWT 저장 및 메시지 표시
├── main.js # 애플리케이션 진입점
├── router/
│ └── index.js # Vue Router 설정
└── assets/ # 정적 리소스 (예: 이미지, CSS)
1. main.js
Vue 애플리케이션의 진입점입니다.
App.vue
를 루트 컴포넌트로 설정하고 DOM에 마운트합니다.import { createApp } from 'vue';
import App from './App.vue';
import router from './router';
createApp(App).use(router).mount('#app');
2. router/index.js
Vue Router 설정 파일입니다.
/
)에 접근 시 /login
으로 리디렉션합니다.import { createRouter, createWebHistory } from 'vue-router';
const routes = [
{ path: '/', redirect: '/login' }, // 기본 경로는 /login으로 리디렉션
];
const router = createRouter({
history: createWebHistory(), // 브라우저의 히스토리 모드를 사용
routes,
});
export default router;
3. api/axios.js
Axios 인스턴스 설정 파일입니다.
baseURL
과 공통 옵션을 설정합니다.import axios from 'axios';
const instance = axios.create({
baseURL: 'http://localhost:8080', // Spring Boot 서버 주소
withCredentials: true, // 인증 정보를 함께 전송
});
export default instance;
4. App.vue
애플리케이션의 메인 레이아웃을 정의합니다.
HelloWorld.vue
)를 렌더링합니다.<template>
<HelloWorld msg="helloworld" />
</template>
<script>
import HelloWorld from './components/HelloWorld.vue';
export default {
name: 'App',
components: {
HelloWorld,
},
};
</script>
<style scoped>
.container {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 100vh;
text-align: center;
background-color: #f9f9f9;
}
</style>
5. components/HelloWorld.vue
로그인 성공 후 JWT 저장 및 사용자 메시지를 표시합니다.
<template>
<div class="container">
<h1 class="title">로그인 성공!</h1>
<p class="message">환영합니다. JWT가 저장되었습니다.</p>
<button class="button" @click="goToHome">홈으로 이동</button>
</div>
</template>
<script>
export default {
mounted() {
// URL에서 토큰 추출
const urlParams = new URLSearchParams(window.location.search);
const token = urlParams.get('token');
if (token) {
// JWT를 로컬 스토리지에 저장
localStorage.setItem('jwt', token);
} else {
alert('토큰이 전달되지 않았습니다. 다시 로그인해주세요.');
}
},
methods: {
goToHome() {
// 홈으로 이동
this.$router.push('/');
},
},
};
</script>
<style scoped>
/* 전체 화면 중앙 배치 */
.container {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 100vh;
background-color: #f9f9f9;
text-align: center;
}
/* 타이틀 스타일 */
.title {
font-size: 2.5rem;
color: #333;
margin-bottom: 1rem;
}
/* 메시지 스타일 */
.message {
font-size: 1.2rem;
margin-bottom: 2rem;
}
/* 버튼 스타일 */
.button {
padding: 0.8rem 2rem;
font-size: 1rem;
background-color: #4285f4;
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
transition: background-color 0.3s ease;
}
.button:hover {
background-color: #357ae8;
}
</style>
main.js
: 애플리케이션 초기화 → Vue Router와 App.vue
를 DOM에 연결.router/index.js
: 경로 관리 → 기본 경로(/
)를 /login
으로 리디렉션.api/axios.js
: Axios 설정 → HTTP 요청 시 기본 URL과 옵션 적용.App.vue
: HelloWorld.vue
를 렌더링 → 주요 컴포넌트를 중앙 배치.HelloWorld.vue
:사용자 로그인 후 리디렉션:
http://localhost:8081/oauth2/redirect?token=<JWT>
.JWT 추출 및 저장:
HelloWorld.vue
에서 URL에서 token
을 추출.localStorage
에 저장하여 인증 요청 시 사용.홈 버튼 클릭:
/
경로로 이동.Axios를 사용한 요청:
api/axios.js
에서 설정한 Axios 인스턴스를 활용하여 JWT 포함 요청 가능.일단 package.json에서 vue-cli-service serve를 통해 vue.js 서버를 띄웁니다.
DONE Compiled successfully in 906ms 3:02:41 PM
App running at:
- Local: http://localhost:8081/
- Network: http://172.30.1.1:8081/
Note that the development build is not optimized.
To create a production build, run npm run build.
스프링을 8080으로 띄우고 vue.js를 8081로 띄어주었습니다.
그리고 localhost:8080으로 들어가면 /login으로 redircetion이 됩니다.
이후, 깃허브나 구글을 눌러봅니다. (저는 구글로 해보겠습니다.)
이런 화면이 뜨고
로그인을 하였을 때, 이렇게 로그인 성공 화면이 나오면 jwt + oauth2 성공입니다.
그리고 url에서
http://localhost:8081/oauth2/redirect?token=e..
이런식으로 redirect가 정상적으로 되었다는 것을 확인하실 수 있습니다.
지금까지 대략적인 깃허브, 구글 로그인을 살펴보았습니다.
전체 코드 관련해서는
SocialLogin <- 여기를 참고하시면 되겠습니다!