OAuth 클라이언트는 구글을 이용하였다.
spring:
datasource:
url: ${DB_URL}
username: ${DB_USERNAME}
password: ${DB_PW}
driver-class-name: com.mysql.cj.jdbc.Driver
jpa:
properties:
hibernate:
dialect: org.hibernate.dialect.MySQLDialect
hbm2ddl:
auto: create
show-sql: true
oauth2:
login-url: https://accounts.google.com/o/oauth2/v2/auth
client-id: ${OAUTH_CLIENT_ID}
client-pw: ${OAUTH_CLIENT_PW}
redirect-uri: ${OAUTH_CALLBACK_URL}
token-uri: https://oauth2.googleapis.com/token
user-info-uri: https://www.googleapis.com/userinfo/v2/me
jwt:
secret-key: ${JWT_SECRET}
${}
로 표시된 값들은 실제 값을 넣어야 한다. (혹은 환경변수 등으로 설정하여 주입해주어야 한다)
인증 절차를 그림으로 정리해보면 다음과 같다.
프론트 측에서 client-id
, client-secret
을 가지고 OAuth 인증 URL를 직접 접근할 수도 있지만 서버에서 해당 URL을 먼저 내려주고 인증 절차를 진행하는 것으로 구성하였다.
인가 절차는 다음과 같이 정리할 수 있다.
먼저 사용자를 나타낼 엔티티 클래스를 구성하였다.
package com.example.springallinoneproject.user.entity;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = "users")
@Getter
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String email;
private String username;
private String password;
@Enumerated(value = EnumType.STRING)
private SocialType socialType;
@Builder
public User(String email, String username, String password, SocialType socialType) {
this.email = email;
this.username = username;
this.password = password;
this.socialType = socialType;
}
}
이 프로젝트에서는 구글 OAuth 만을 이용하여 소셜 로그인을 구현하였지만 추후 다른 OAuth로 확장될 가능성을 염두해두고 SocialType
열거형을 소셜 로그인 타입을 표현하였다.
package com.example.springallinoneproject.user.entity;
public enum SocialType {
GOOGLE, NAVER, GITHUB
}
이 프로젝트에서 구현한 구글 소셜 로그인은 다음 절차를 걸쳐 이뤄진다.
code
가 쿼리스트링으로 제공된다.code
를 통해 유저 리소스에 접근할 수 있는 토큰은 [oauth2.googleapis.com/token](http://oauth2.googleapis.com/token)
에 공식 문서에서 정의한 바디 값을 포함한 POST 요청을 통해 받을 수 있다.[www.googleapis.com/userinfo/v2/me](http://www.googleapis.com/userinfo/v2/me)
에 접근 토큰을 쿼리스트링으로 포함하여 GET 요청해 이메일, 유저 이름과 같은 데이터를 받을 수 있다.package com.example.springallinoneproject.user.login;
import com.example.springallinoneproject.user.dto.LoggedInUser;
import com.example.springallinoneproject.user.dto.UserProfileResponse;
import com.example.springallinoneproject.user.entity.SocialType;
import com.example.springallinoneproject.user.entity.User;
import com.example.springallinoneproject.user.repository.UserRepository;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders;
import org.springframework.stereotype.Service;
import org.springframework.web.reactive.function.client.WebClient;
@Service
@RequiredArgsConstructor
@Slf4j
public class LoginService {
private final UserRepository userRepository;
@Value("${oauth2.login-url}")
private String loginUrl;
@Value("${oauth2.client-id}")
private String clientId;
@Value("${oauth2.client-pw}")
private String clientPassword;
@Value("${oauth2.redirect-uri}")
private String redirectUri;
@Value("${oauth2.token-uri}")
private String tokenUri;
@Value("${oauth2.user-info-uri}")
private String userInfoUri;
public String getUrl() {
return loginUrl + "?client_id=" + clientId + "&redirect_uri=" + redirectUri
+ "&response_type=code&scope=email profile";
}
public String getToken(String code) {
WebClient webClient = WebClient
.builder()
.baseUrl("https://oauth2.googleapis.com")
.build();
HashMap<String, Object> requestBody = new HashMap<>();
requestBody.put("code", code);
requestBody.put("client_id", clientId);
requestBody.put("client_secret", clientPassword);
requestBody.put("redirect_uri", redirectUri);
requestBody.put("grant_type", "authorization_code");
Map<String, Object> response = webClient.post()
.uri(uriBuilder -> uriBuilder
.path("/token")
.queryParam("code", code)
.build())
.header(HttpHeaders.ACCEPT, "application/json")
.bodyValue(requestBody)
.retrieve()
.bodyToMono(Map.class)
.block();
return (String) response.get("access_token");
}
public UserProfileResponse getUserProfile(String accessToken) {
WebClient webClient = WebClient
.builder()
.baseUrl("https://www.googleapis.com")
.build();
Map<String, Object> response = webClient.get()
.uri(uriBuilder -> uriBuilder
.path("userinfo/v2/me")
.queryParam("access_token", accessToken)
.build())
.retrieve()
.bodyToMono(Map.class)
.block();
String email = (String) response.get("email");
String name = (String) response.get("name");
return UserProfileResponse.builder()
.email(email)
.username(name)
.build();
}
public LoggedInUser createUser(UserProfileResponse userProfile) {
String email = userProfile.getEmail();
Optional<User> signedUser = userRepository.findByEmail(email);
if (signedUser.isPresent()) {
return new LoggedInUser(signedUser.get());
}
User user = User.builder()
.email(userProfile.getEmail())
.username(userProfile.getUsername())
.password("1234")
.socialType(SocialType.GOOGLE)
.build();
return new LoggedInUser(userRepository.save(user));
}
}
application.yml
에 명시된 값을 @Value
어노테이션을 통해 주입받도록 구성하였다.JWT를 생성하고 검증하는 기능들을 한 클래스에서 전담하여 제공할 수 있도록 JwtUtil
클래스를 구현하였다.
package com.example.springallinoneproject.user.login;
import com.auth0.jwt.JWT;
import com.auth0.jwt.algorithms.Algorithm;
import com.example.springallinoneproject.user.dto.LoggedInUser;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.time.Instant;
import java.util.Base64;
import java.util.LinkedHashMap;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
@Component
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class JwtUtil {
@Value("${jwt.secret-key}")
private String secret;
private int expirationTimeMillis = 864_000_000; // 10일(밀리 초 단위)
private String tokenPrefix = "Bearer ";
private ObjectMapper objectMapper = new ObjectMapper();
public boolean isIncludeTokenPrefix(String header) {
return header.split(" ")[0].equals(tokenPrefix.trim());
}
public String extractTokenFromHeader(String header) {
return header.replace(tokenPrefix, "");
}
public String createToken(LoggedInUser loggedInUser, Instant currentDate) {
String token = JWT.create()
.withSubject(loggedInUser.getEmail())
.withExpiresAt(currentDate.plusMillis(expirationTimeMillis))
.withClaim("email", loggedInUser.getEmail())
.withClaim("username", loggedInUser.getUsername())
.sign(Algorithm.HMAC512(secret));
return tokenPrefix.concat(token);
}
public boolean isTokenExpired(String token) {
Instant expiredAt = JWT.require(Algorithm.HMAC512(secret))
.build().verify(token)
.getExpiresAtAsInstant();
return expiredAt.isBefore(Instant.now());
}
public boolean isTokenNotManipulated(String token) {
return JWT.require(Algorithm.HMAC512(secret))
.build().verify(token)
.getSignature()
.equals(secret);
}
public LoggedInUser extractUserFromToken(String token) {
String payload = JWT.decode(token)
.getPayload();
byte[] decodedBytes = Base64.getDecoder().decode(payload);
String decodedPayload = new String(decodedBytes);
return parseUserFromJwt(decodedPayload);
}
private LoggedInUser parseUserFromJwt(String decodedPayload) {
try {
LinkedHashMap<String, Object> payloadMap = objectMapper.readValue(decodedPayload, LinkedHashMap.class);
String email = (String) payloadMap.get("email");
String username = (String) payloadMap.get("username");
return new LoggedInUser(email, username);
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
}
}
package com.example.springallinoneproject.user.login;
import com.example.springallinoneproject.user.dto.JwtResponse;
import com.example.springallinoneproject.user.dto.LoggedInUser;
import com.example.springallinoneproject.user.dto.UserProfileResponse;
import java.time.Instant;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequiredArgsConstructor
@Slf4j
public class LoginController {
private final LoginService loginService;
private final JwtUtil jwtUtil;
@GetMapping("/login")
public String login() {
return loginService.getUrl();
}
@GetMapping("/login/oauth2/code/google")
public ResponseEntity<JwtResponse> oauthLogin(String code) {
String accessToken = loginService.getToken(code);
UserProfileResponse userProfile = loginService.getUserProfile(accessToken);
LoggedInUser loggedInUser = loginService.createUser(userProfile);
String token = jwtUtil.createToken(loggedInUser, Instant.now());
return ResponseEntity.ok(new JwtResponse(token));
}
@GetMapping("/api/v1/user")
public String user(){
return "<h1>user</h1>";
}
@GetMapping("/api/v1/manager")
public String manager(){
return "<h1>manager</h1>";
}
}
프론트에서는 /login
에 GET 요청을 통해 구글 소셜 로그인 URL을 받을 수 있으며 해당 URL을 통해 유저가 인증 절차를 다 완료할 경우 콜백 URL을 통해 code
가 넘어온다. /login/oauth2/code/google
경로로 콜백되도록 구성하였다. 유저 데이터를 기반으로 생성된 JWT는 JwtResponse
DTO 형태로 응답 바디를 통해 클라이언트에게 전달된다.
package com.example.springallinoneproject.user.dto;
import lombok.AllArgsConstructor;
import lombok.Getter;
@Getter
@AllArgsConstructor
public class JwtResponse {
private String token;
}
로그인 여부를 검증하는 기능과 JWT를 검증하는 기능을 다른 인터셉터로 나누어 구현하였다. 흐름은 로그인 여부를 먼저 검증하고(Authorization
헤더의 존재 여부) JWT를 검증하는 식이다.
package com.example.springallinoneproject.interceptor;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpHeaders;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
@Component
@RequiredArgsConstructor
public class LoginInterceptor implements HandlerInterceptor {
private final JwtInterceptor jwtInterceptor;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
throws Exception {
if(request.getHeader(HttpHeaders.AUTHORIZATION)==null){
throw new IllegalStateException("no Authorization");
}
jwtInterceptor.preHandle(request, response, handler);
return true;
}
}
Authorization
헤더가 존재하면 JwtInterceptor
에게 JWT 검증을 위임한다.
package com.example.springallinoneproject.interceptor;
import com.example.springallinoneproject.user.login.JwtUtil;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpHeaders;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
@Component
@RequiredArgsConstructor
public class JwtInterceptor implements HandlerInterceptor {
private final JwtUtil jwtUtil;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
throws Exception {
String header = request.getHeader(HttpHeaders.AUTHORIZATION);
if(!jwtUtil.isIncludeTokenPrefix(header)){
throw new IllegalStateException("No Bearer prefix");
}
String token = jwtUtil.extractTokenFromHeader(header);
if(jwtUtil.isTokenExpired(token)){
throw new IllegalStateException("token expired");
}
if(jwtUtil.isTokenNotManipulated(token)){
throw new IllegalStateException("token manipulated");
}
return true;
}
}
Bearer
토큰 여부, 토큰 만료 여부, 토큰 시그니처를 검증한다.
구현한 인터셉터를 등록하여 잘 동작할 수 있도록 설정한다.
package com.example.springallinoneproject.config;
import com.example.springallinoneproject.interceptor.LoginInterceptor;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {
private final LoginInterceptor loginInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(loginInterceptor)
.addPathPatterns("/api/v1/user/**")
.addPathPatterns("/api/v1/manager/**");
}
}