[Spring Security] JWT을 이용한 소셜 로그인 구현
2023. 2. 1. 22:35ㆍSpring
JWT이란?
Json Web Token의 약자로서 암호화된 데이터를 전송하기 위한 Json 기반의 인터넷 표준이다.
유저 인증시 사용하는 토큰으로, 유저의 인증 정보를 서버가 아닌 클라이언트가 들고 있기 때문에 서버의 부담을 덜 수 있다.
서버는 유저 요청시 토큰이 유효한지만 판별하면 된다.
JWT을 쓰는 이유?
이전 포스팅에서 다룬 Session-Cookie 인증 방식은 다음과 같은 단점이 있다.
- 휘발성 메모리 영역에 저장된다.
- 세션은 결국 메모리 위에서 동작하기 때문에 만에 하나 서버가 종료되면 세션이 초기화되고 모든 유저는 다시 로그인해야한다.
- 분산 서버 환경에서 문제가 될 수 있다.
- 분산 서버 환경에선 서버의 상태를 stateless하게 설계해야하는데 세션&쿠키 인증 방식은 필연적으로 서버가 유저의 상태 정보를 유지해야한다.
- 분산 서버 환경에서 유저가 로그인한 이력이 있더라도 다른 세션 저장소를 가진 서버가 요청을 받았기 때문에 다시 로그인해야하는 문제가 발생한다.
JWT 인증 방식은 서버가 유저 상태 정보를 들고 있지 않기 때문에 분산 서버 환경에서 유리하기 때문에 사용한다.
JWT의 구조
jwt은 header, payload, signature로 이루어진다.
다음은 HMAC 암호화 방식을 이용한 것이다.
HMAC은 임의의 시크릿 키를 포함한 암호화 방식이다.

- header에는 사용할 토큰 타입과 알고리즘의 종류가 담긴다.
- payload에는 유저 이름과 권한 정보와 같은 Claim 정보가 담긴다.
- signature에는 header에서 선정한 알고리즘으로 header + payload + 서버에서 생성한 secret key 를 암호화한 데이터가 담긴다.
각각은 Base64 알고리즘으로 인코딩되어있다.
JWT을 이용한 인증 Flow

- 클라이언트는 id, password를 이용해서 로그인한다.
- 서버는 header, payload, signature 세가지 정보로 토큰을 생성함
- 중요한 것은 signature로, 유저가 정상 인증된 시점에 서버가 서명을 했다는 증명을 나타낸다.
- 만들어진 토큰을 클라이언트에게 전달하고 브라우저의 스토리지 영역에 토큰을 저장한다.
- 클라이언트는 다음 요청때마다 토큰을 같이 보낸다.
- 서버는 토큰이 유효한지 검증해보고 유효하다면 올바른 요청으로 인식하고 애플리케이션 프로세스를 진행한다.
- 토큰 유효성 검증시 서버가 가진 시크릿 키로 signature를 다시 생성해서 요청과 일치하는지만 확인하면 된다.
JWT의 한계
JWT는 다음의 한계를 가진다.
- 토큰 데이터 길이가 길어서 네트워크 부하가 발생
- 토큰 탈취 문제
- payload에 민감한 데이터를 담을 수 없다.
- 서버쪽에서 유저의 접속 유무를 알 수 없다.
Access Token & Refresh Token 방식
토큰 탈취의 위험성을 줄이기 위해 토큰의 수명을 관리하기 위해 사용한다.
Access Token
실제 정보가 담긴 토큰이며 수명이 30분 정도로 매우 짧다.
Refresh Token
만료된 Access Token을 재발급하기 위한 토큰으로 Access Token보다 수명이 길고 DB에서 관리한다.
하지만 결국 Refresh Token도 탈취 위험이 있기 때문에 http only, secure http, ssl을 적용해야한다.
인증 Flow

예제 코드
build.gradle
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5', 'io.jsonwebtoken:jjwt-jackson:0.11.5'
}
- jjwt 라이브러리를 사용했다.
application.yml
server:
port: 8080 # 사용 포트
servlet:
context-path: /
encoding:
charset: UTF-8
enabled: true
force: true
spring:
datasource:
url: {url}
username: {username}
password: {password}
driver-class-name: com.mysql.cj.jdbc.Driver
jpa:
hibernate:
ddl-auto: update #create update none
naming:
physical-strategy: org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl
show-sql: true
security:
oauth2:
client:
registration:
naver:
client-id: {id}
client-secret: {secret}
scope:
- name
- email
client-name: Naver # 클라이언트 네임은 구글 페이스북도 대문자로 시작하더라.
authorization-grant-type: authorization_code # 코드를 부여받는 방식
redirect-uri:
# 네이버는 OAuth2.0 공식 지원대상이 아니라서 별도로 "naver"라는 provider를 등록해줘야함
# provider 등록을 안하면 "clientRegistrationRepository"에 "naver"라는 id로 등록된 provider가 없다고 에러남
# 요청주소도 다르고, 응답 데이터도 다르기 때문이다.
provider:
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 # 회원정보를 json의 response 키값으로 리턴해줌.
jwt:
access-token-key: {access-token-key}
refresh-token-key: {refresh-token-key}
app:
oauth2:
authorized-redirect-uri: # 인증후 리다이렉트 주소
- access-token-key: Access Token을 발급하기 위한 private key
- refresh-token-key: Refresh Token을 발급하기 위한 private key
- authorized-redirect-uri
- 인증 이후 리다이렉트될 uri를 가리킴
- SPA의 경우 서버 URI와 미들웨어 URI가 달라서 CORS 이슈가 발생할 수 있다.
SecurityConfig
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(securedEnabled = true, prePostEnabled = true) // 선택적으로 권한 처리시 @Secured나 @PreAuthorize 사용
@RequiredArgsConstructor
public class SecurityConfig {
private final PrincipalOauth2UserService principalOauth2UserService;
private final CookieAuthorizationRequestRepository cookieAuthorizationRequestRepository;
private final OAuth2AuthenticationSuccessHandler oAuth2AuthenticationSuccessHandler;
private final OAuth2AuthenticationFailureHandler oAuth2AuthenticationFailureHandler;
private final JwtAuthenticationFilter jwtAuthenticationFilter;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.csrf().disable()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
http.authorizeRequests()
.antMatchers("/user/**").authenticated()
.antMatchers("/manager/**").access(String.format("hasRole('%s') or hasRole('%s')", UserRole.ROLE_MANAGER.getRole(), UserRole.ROLE_ADMIN.getRole()))
.antMatchers("/admin/**").access(String.format("hasRole('%s')", UserRole.ROLE_ADMIN.getRole()))
.anyRequest().permitAll();
http.formLogin();
http.oauth2Login()
.authorizationEndpoint()
.baseUri("/oauth2/authorization")
.authorizationRequestRepository(cookieAuthorizationRequestRepository)
.and()
.userInfoEndpoint()
.userService(principalOauth2UserService)
.and()
.successHandler(oAuth2AuthenticationSuccessHandler)
.failureHandler(oAuth2AuthenticationFailureHandler)
.and()
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}
- sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
- 서버는 세션을 유지하지 않는다.
- oauth2Login()
- authorizationEndpoint()
- 소셜 로그인 제공사 서버에 보내는 요청에 관련된 설정이다.
- authorizationRequestRepository()
- 인가 처리 동안 OAuth2AuthorizationRequest를 유지하는 레포지토리를 설정한다.
- 디폴트 구현체는 HttpSessionOAuth2AuthorizationRequestRepository 이지만 세션이 아닌 쿠키에 저장할 것이기 때문에 커스텀 구현체를 주입한다.
- userInfoEndpoint()
- userService()
- 성공적으로 인증한 로그인 정보를 가지고 비스니스 로직을 처리할 커스텀 구현체를 주입한다.
- userService()
- successHandler()
- 인증 성공시 동작할 구현체 주입
- failureHandler()
- 인증 실패시 동작할 구현체 주입
- addFilterBefore()
- 모든 요청마다 인가 작업을 처리할 filter 구현체 주입
- authorizationEndpoint()
User
@Entity
@Getter
@Builder
@ToString
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true)
private String username;
private String password;
private String email;
@Enumerated(EnumType.STRING)
private UserRole UserRole;
@Enumerated(EnumType.STRING)
private AuthProvider authProvider;
private String refreshToken;
private Timestamp createDate;
private Timestamp updateDate;
public User() {}
public User(Long id, String username, String password, String email, UserRole UserRole, AuthProvider authProvider, String refreshToken, Timestamp createDate, Timestamp updateDate) {
this.id = id;
this.username = username;
this.password = password;
this.email = email;
this.UserRole = UserRole;
this.authProvider = authProvider;
this.refreshToken = refreshToken;
this.createDate = createDate;
this.updateDate = updateDate;
}
}
CookieAuthorizationRequestRepository
@Component
public class CookieAuthorizationRequestRepository implements AuthorizationRequestRepository<OAuth2AuthorizationRequest> {
public static final String OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME = "oauth2_auth_request";
public static final String REDIRECT_URI_PARAM_COOKIE_NAME = "redirect_uri";
private static final int COOKIE_EXPIRE_SECONDS = 180;
@Override
public OAuth2AuthorizationRequest loadAuthorizationRequest(HttpServletRequest request) {
return CookieUtil.getCookie(request, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME)
.map(cookie -> CookieUtil.deserialize(cookie, OAuth2AuthorizationRequest.class))
.orElse(null);
}
@Override
public void saveAuthorizationRequest(OAuth2AuthorizationRequest authorizationRequest, HttpServletRequest request, HttpServletResponse response) {
if (authorizationRequest == null) {
CookieUtil.deleteCookie(request, response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME);
CookieUtil.deleteCookie(request, response, REDIRECT_URI_PARAM_COOKIE_NAME);
return;
}
CookieUtil.addCookie(response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME, CookieUtil.serialize(authorizationRequest), COOKIE_EXPIRE_SECONDS);
String redirectUriAfterLogin = request.getParameter(REDIRECT_URI_PARAM_COOKIE_NAME);
if (StringUtils.isNotBlank(redirectUriAfterLogin)) {
CookieUtil.addCookie(response, REDIRECT_URI_PARAM_COOKIE_NAME, redirectUriAfterLogin, COOKIE_EXPIRE_SECONDS);
}
}
@Override
public OAuth2AuthorizationRequest removeAuthorizationRequest(HttpServletRequest request) {
return this.loadAuthorizationRequest(request);
}
@Override
public OAuth2AuthorizationRequest removeAuthorizationRequest(HttpServletRequest request, HttpServletResponse response) {
return AuthorizationRequestRepository.super.removeAuthorizationRequest(request, response);
}
public void removeAuthorizationRequestCookies(HttpServletRequest request, HttpServletResponse response) {
CookieUtil.deleteCookie(request, response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME);
CookieUtil.deleteCookie(request, response, REDIRECT_URI_PARAM_COOKIE_NAME);
}
}
- oauth2_auth_request 쿠키에는 Authorization Code가 Authentication Request 형태로 저장되어 있다.
- redirect_uri는 인증 완료후 리다이렉트할 주소 정보가 담겨있다.
PrincipalOAuth2UserService
@Service
@Slf4j
@RequiredArgsConstructor
public class PrincipalOauth2UserService extends DefaultOAuth2UserService {
private final UserService userService;
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
OAuth2User oAuth2User = super.loadUser(userRequest);
UserJoinRequest userJoinRequest = parseToUserJoinRequest(userRequest, oAuth2User);
User find = userService.findByUsername(userJoinRequest.getUsername()).orElseGet(() -> saveOAuthUser(userJoinRequest));
return new PrincipalDetails(find, oAuth2User.getAttributes());
}
private UserJoinRequest parseToUserJoinRequest(OAuth2UserRequest userRequest, OAuth2User oAuth2User) {
log.info("======global login======");
log.info("getClientRegistration : {}", userRequest.getClientRegistration());
log.info("getAccessToken : {}", userRequest.getAccessToken().getTokenValue());
log.info("getAdditionalParameters : {}", userRequest.getAdditionalParameters());
log.info("getAttributes : {}", oAuth2User.getAttributes());
log.info("getAuthorities : {}", oAuth2User.getAuthorities());
AuthProvider authProvider = AuthProvider.valueOf(userRequest.getClientRegistration().getRegistrationId().toUpperCase());
Map<String, Object> attributes = oAuth2User.getAttributes();
OAuth2UserInfo oAuth2UserInfo = OAuth2UserInfoFactory.getOAuth2UserInfo(authProvider, attributes);
String username = String.format("%s_%s", authProvider.getProviderId(), oAuth2UserInfo.getId());
String password = "defaultPassword";
String email = oAuth2UserInfo.getEmail();
return UserJoinRequest.builder()
.username(username)
.password(password)
.email(email)
.userRole(UserRole.ROLE_USER)
.authProvider(authProvider)
.build();
}
private User saveOAuthUser(UserJoinRequest userJoinRequest) {
log.info("최초 소셜 로그인이 감지되어 회원가입합니다.");
return userService.saveUser(userJoinRequest);
}
}
- Access Token의 username 정보를 바탕으로 유저 정보를 db로부터 가져온다.
OAuth2AuthenticationSuccessHandler
@Slf4j
@Component
@RequiredArgsConstructor
public class OAuth2AuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
@Value("${app.oauth2.authorized-redirect-uri}")
private String redirectUri;
private final TokenProvider tokenProvider;
private final CookieAuthorizationRequestRepository authorizationRequestRepository;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authentication) throws IOException, ServletException {
String targetUrl = determineTargetUrl(request, response, authentication);
if (response.isCommitted()) {
log.debug("Response has already been committed");
return;
}
clearAuthenticationAttributes(request, response);
getRedirectStrategy().sendRedirect(request, response, targetUrl);
}
protected String determineTargetUrl(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
Optional<String> redirectUri = CookieUtil.getCookie(request,REDIRECT_URI_PARAM_COOKIE_NAME)
.map(Cookie::getValue);
if (redirectUri.isPresent() && !isAuthorizedRedirectUri(redirectUri.get())) {
throw new BadRequestException("redirect URIs are not matched");
}
String targetUrl = redirectUri.orElse(getDefaultTargetUrl());
// JWT 생성
String accessToken = tokenProvider.createAccessToken(authentication);
tokenProvider.createAndSaveRefreshToken(authentication, response);
return UriComponentsBuilder.fromUriString(targetUrl)
.queryParam("accessToken", accessToken)
.build().toUriString();
}
protected void clearAuthenticationAttributes(HttpServletRequest request, HttpServletResponse response) {
super.clearAuthenticationAttributes(request);
authorizationRequestRepository.removeAuthorizationRequest(request, response);
}
private boolean isAuthorizedRedirectUri(String uri) {
URI clientRedirectUri = URI.create(uri);
URI authorizedUri = URI.create(redirectUri);
return authorizedUri.getHost().equalsIgnoreCase(clientRedirectUri.getHost())
&& authorizedUri.getPort() == clientRedirectUri.getPort();
}
}
- Token Provider에게 토큰 생성 처리를 위임하고 생성된 토큰을 Client에게 전달한다.
OAuth2AuthenticationFailureHandler
@Component
@RequiredArgsConstructor
@Slf4j
public class OAuth2AuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler {
private final CookieAuthorizationRequestRepository cookieAuthorizationRequestRepository;
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
String targetUrl = CookieUtil.getCookie(request,REDIRECT_URI_PARAM_COOKIE_NAME)
.map(Cookie::getValue)
.orElse("/");
targetUrl = UriComponentsBuilder.fromUriString(targetUrl)
.queryParam("error", exception.getLocalizedMessage().replaceAll("[\\\\[\\\\]]", ""))
.build().toUriString();
cookieAuthorizationRequestRepository.removeAuthorizationRequestCookies(request, response);
getRedirectStrategy().sendRedirect(request, response, targetUrl);
}
}
- OAuth 로그인 실패시 쿠키를 삭제하고 에러를 담아 Client에게 보낸다.
TokenProvider
@Slf4j
@Component
public class TokenProvider {
private final Key accessTokenKey;
private final Key refreshTokenKey;
private static final LongACCESS_TOKEN_EXPIRE_TIME= 1000L * 60 * 60;
private static final LongREFRESH_TOKEN_EXPIRE_TIME= 1000L * 60 * 60 * 24 * 7;
private final UserRepository userRepository;
public TokenProvider(@Value("${jwt.access-token-key}") String SECRET_KEY, @Value("${jwt.refresh-token-key}") String REFRESH_TOKEN_KEY, UserRepository userRepository) {
this.userRepository = userRepository;
byte[] encodedAccessTokenKey = Base64.getEncoder().encodeToString(SECRET_KEY.getBytes()).getBytes();
this.accessTokenKey = Keys.hmacShaKeyFor(encodedAccessTokenKey);
byte[] encodedRefreshTokenKey = Base64.getEncoder().encodeToString(REFRESH_TOKEN_KEY.getBytes()).getBytes();
this.refreshTokenKey = Keys.hmacShaKeyFor(encodedRefreshTokenKey);
}
public String createAccessToken(Authentication authentication) {
Date now = new Date();
Date expiredDate = new Date(now.getTime() +ACCESS_TOKEN_EXPIRE_TIME);
PrincipalDetails userDetails = (PrincipalDetails) authentication.getPrincipal();
String username = userDetails.getUsername();
String role = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining(","));
return Jwts.builder()
.setSubject(username)
.claim("role", role)
.setIssuedAt(now)
.setExpiration(expiredDate)
.signWith(accessTokenKey)
.compact();
}
public void createAndSaveRefreshToken(Authentication authentication, HttpServletResponse response) {
Date now = new Date();
Date expiredDate = new Date(now.getTime() +REFRESH_TOKEN_EXPIRE_TIME);
String refreshToken = Jwts.builder()
.setIssuedAt(now)
.setExpiration(expiredDate)
.signWith(refreshTokenKey)
.compact();
PrincipalDetails userDetails = (PrincipalDetails) authentication.getPrincipal();
String username = userDetails.getUsername();
userRepository.updateRefreshTokenByUsername(username, refreshToken);
CookieUtil.addSameSiteCookie(response, "refresh_token", refreshToken, (int) (REFRESH_TOKEN_EXPIRE_TIME/ 1000));
}
// Access Token 을 검사하고 얻은 정보로 Authentication 객체 생성
public Authentication getAuthentication(String accessToken) {
Claims claims = parseClaims(accessToken);
User find = userRepository.findByUsername(claims.getSubject()).orElseThrow(() -> new UsernameNotFoundException("username is not found"));
Collection<? extends GrantedAuthority> authorities = Arrays.stream(claims.get("role").toString().split(","))
.map(SimpleGrantedAuthority::new).collect(Collectors.toList());
PrincipalDetails principal = new PrincipalDetails(find);
return new UsernamePasswordAuthenticationToken(principal, "", authorities);
}
public Boolean validateToken(String token) {
try {
Jwts.parserBuilder()
.setSigningKey(accessTokenKey)
.build()
.parseClaimsJws(token);
return true;
} catch (ExpiredJwtException e) {
log.info("만료된 JWT 토큰입니다.");
} catch (UnsupportedJwtException e) {
log.info("지원되지 않는 JWT 토큰입니다.");
} catch (IllegalStateException e) {
log.info("JWT 토큰이 잘못되었습니다");
}
return false;
}
// Access Token 만료시 갱신때 사용할 정보를 얻기 위해 Claim 리턴
private Claims parseClaims(String accessToken) {
try {
return Jwts.parserBuilder()
.setSigningKey(accessTokenKey)
.build()
.parseClaimsJws(accessToken)
.getBody();
} catch (ExpiredJwtException e) {
return e.getClaims();
}
}
}
- Access Token과 Refresh Token을 생성 및 검증한다.
코드 참조
https://github.com/KIM-KYOUNG-OH/Education-Lab
Reference
- https://www.youtube.com/watch?v=cv6syIv-8eo&list=PL93mKxaRDidERCyMaobSLkvSPzYtIk0Ah&index=13
- https://github.com/codingspecialist/Springboot-Security-JWT-Easy
- https://inpa.tistory.com/entry/WEB-📚-JWTjson-web-token-란-💯-정리#Token_인증
- https://europani.github.io/spring/2022/01/15/036-oauth2-jwt.html#h-프로젝트-구조
- https://github.com/jwtk/jjwt
- https://velog.io/@max9106/OAuth3
- https://www.callicoder.com/spring-boot-security-oauth2-social-login-part-1/
'Spring' 카테고리의 다른 글
[Spring Security] 소셜 로그인 구현하기(OAuth2.0, naver) (0) | 2023.01.16 |
---|---|
[Spring Security] 웹사이트 자체 로그인 구현하기 (0) | 2023.01.12 |