프로젝트를 진행하면서 SpringSecurty부분을 구현하면서 팀원들과 대화가 안되어 너무 답답한 마음에 공부한것을 작성합니다.
- 대부분 ChatGPT와 대화 형식으로 궁금한 부분들을 질문하며 공부한 내용입니다.
인증과 인가에 대한 개념은 다른 글에서 작성했으므로 생략.
Resource Owner(사용자) 로그인 요청: 사용자가 애플리케이션을 통해 로그인을 요청합니다.
Client가 Authorization Server에 인증 요청:
spring:
security:
oauth2:
client:
registration:
kakao: //OAuth2UserService.java의 registrationId에 해당
client-id: [client-id]
redirect-uri: http://localhost:3000/oauth/kakao
client-authentication-method: POST
client-secret: [secret-key]
authorization-grant-type: authorization_code
scope:
- profile_nickname
- profile_image
- account_email
- gender
- birthday
client_name: kakao
provider:
kakao:
authorization-uri: https://kauth.kakao.com/oauth/authorize
token-uri: https://kauth.kakao.com/oauth/token
user-info-uri: https://kapi.kakao.com/v2/user/me
user-name-attribute: id
jwt:
token:
secret-key: [secret-key]
access-token:
expire-length: 1800000
refresh-token:
expire-length: 1209600000
위와 같이 applicayion-oauth.yaml파일을 설정하고 등록해주면 spring이 4,5,6,7번의 과정을 알아서 해준다.
authorization-uri
는 사용자가 카카오 계정으로 로그인 하기
와 같은 버튼을 클릭하면 보여지는 페이지이다.
token-uri
는 액세스 토큰 및 리프레시 토큰을 요청할 때 사용되는 URI이다.
위 설정파일을 기준으로 로그인을 하게 되면 리디렉션된 서버인localhost:3000
이 카카오의 Token Endpoint인 token-uri로 HTTP POST요청을 보내면 카카오 서버로부터 Access Token을 수신할 수 있다.
user-info-uri
는 서버가 Access Token을 헤더에 담아서 사용자의 정보를 요청하는 uri이다.
Spring Security에서 OAuth2 클라이언트 설정을 관리하는 클래스이다.
application-oauth.yaml 파일에서 OAuth2 클라이언트 관련 설정을 읽고 Spring 어플리케이션에서 사용할 수 있도록 한다.
클래스 내부를 보면 public static class Registration
과 public static class Provider
가 정의되어 있는것을 확인할 수 있다.
각 클래스의 필드를 보면 Registration
클래스는 사용자의 정보를 저장하는 것을 알 수 있고, Provider
는 OAuth 제공자에 대한 정보를 저장하는 것을 알 수 있다.
Spring Boot는 application-oauth.yaml
파일을 자동으로 읽어들여 Registration
객체를 생성하고 어플리케이션 내에서 OAuth2인증을 위한 클라이언트 설정을 구성한다.
@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties(OAuth2ClientProperties.class)
@Conditional(ClientsConfiguredCondition.class)
class OAuth2ClientRegistrationRepositoryConfiguration {
@Bean
@ConditionalOnMissingBean(ClientRegistrationRepository.class)
InMemoryClientRegistrationRepository clientRegistrationRepository(OAuth2ClientProperties properties) {
List<ClientRegistration> registrations = new ArrayList<>(
new OAuth2ClientPropertiesMapper(properties).asClientRegistrations().values());
return new InMemoryClientRegistrationRepository(registrations);
}
}
OAuth2ClientProperties
를 파라미터로 받아서 ClientRegistration
객체를 생성한다. 이 객체는 OAuth2 클라이언트(예: 카카오)에 대한 구성 정보를 포함한다.
생성된 ClientRegistration
객체들은 InMemoryClientRegistrationRepository
에 저장된다. 이 저장소는 메모리에 OAuth2 클라이언트 정보를 보관하고 애플리케이션이 실행되는 동안 사용된다.
일반적으로 Client에서 Authorization Server로 Authorization Code와 함께 Token을 요청하면 Access Token과 Refresh Token을 함께 받는다. (OAuth Server에 따라 Refresh Token을 제공하지 않을 수 있음)
초기 토큰 발급
사용자가 처음 인증을 하면 Access Token과 Refresh Token을 발급받는다.
토큰 갱신 요청
Access Token이 만료되었을 경우, 클라이언트는 Refresh Token을 통해 OAuth 서버에 토큰 갱신 요청을 한다.
새로운 토큰 발급
OAuth 서버는 요청을 검증한 후, 새로운 Access Token을 발급한다. 경우에 따라 Refresh Token도 발급한다.
Authorization
에 Access Token을 포함시켜서 요청한다. // oauth2 설정
http
.oauth2Login(
loginConfigurer -> loginConfigurer
.userInfoEndpoint(uI -> uI.userService(oAuth2UserService))
.successHandler(customSuccessHandler)
)
;
Spring Security의 설정 중 OAuth2.0 로그인을 구성하는 부분이다.
http.oauth2Login : OAuth2.0 로그인을 활성화 시킨다.
UserInfoEndpoint : 사용자 정보를 가져오는 엔드포인트.
customSuccessHandler : 로그인 프로세스가 성공적으로 완료되었을 때 실행될 핸들러이다.
@Slf4j
@RequiredArgsConstructor
@Service
public class OAuth2UserService extends DefaultOAuth2UserService {
private final MemberRepository memberRepository;
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
OAuth2User oAuth2User = super.loadUser(userRequest); // OAuth 서비스(kakao, naver)에서 가져온 유저 정보를 담고있다.
Map<String, Object> attributes = oAuth2User.getAttributes(); //OAuth 서비스의 유저 정보들
String registrationId = userRequest.getClientRegistration().getRegistrationId(); // OAuth 서비스 이름 (kakao, naver)
String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName(); // OAuth 로그인 시 키(pk)가 되는 값
OAuthAttributes oAuthAttributes = OAuthAttributes.of(registrationId, userNameAttributeName, attributes);
Member member = saveOrUpdate(oAuthAttributes);
return new MyOAuth2Member(Collections.singleton(new SimpleGrantedAuthority(member.getRole().getKey())), oAuthAttributes.nameAttributeKey(), member);
}
/*
* 회원 가입을 하지 않은 유저의 경우 회원 가입이 되며,
* 회원 가입을 이미 한 유저의 경우, 연령대가 업데이트 됩니다.
*/
private Member saveOrUpdate(OAuthAttributes attributes) {
Member member = memberRepository.findByEmail(attributes.email())
.map(
entity -> entity.update(attributes.ageRange())
)
.orElse(attributes.toEntity());
return memberRepository.save(member);
}
}
Map<String, Object> attributes : OAuth2.0 로그인 절차를 통해 인증된 사용자의 속성을 담은 객체이다. name, email, profile image와 같은 것들을 포함하고 있다.
String registrationId
spring:
security:
oauth2:
client:
registration:
kakao:
client-id: [YOUR_CLIENT_ID]
client-secret: [YOUR_CLIENT_SECRET]
...
application-oauth.yaml파일에 위와 같이 설정해주면 kakao
가 registrationId가 된다.
package com.lovely4k.backend.authentication;
import com.lovely4k.backend.authentication.token.TokenDto;
import com.lovely4k.backend.authentication.token.TokenProvider;
import com.lovely4k.backend.couple.Couple;
import com.lovely4k.backend.couple.repository.CoupleRepository;
import com.lovely4k.backend.member.Member;
import com.lovely4k.backend.member.repository.MemberRepository;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.io.IOException;
import java.util.Optional;
@Slf4j
@RequiredArgsConstructor
@Component
@Transactional
public class CustomSuccessHandler implements AuthenticationSuccessHandler {
@Value("${love.service.redirect-url}")
private String redirectUrl;
private final CoupleRepository coupleRepository;
private final MemberRepository memberRepository;
private final TokenProvider tokenProvider;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
log.debug("CustomSuccessHandler 호출");
MyOAuth2Member oAuth2Member = (MyOAuth2Member) authentication.getPrincipal();
Member member = memberRepository.findById(oAuth2Member.getMemberId()).orElseThrow();
TokenDto tokenDto = tokenProvider.generateTokenDto(member);
Long coupleId = oAuth2Member.getCoupleId();
Optional<Couple> optionalCouple = coupleRepository.findDeletedById(coupleId);
optionalCouple.ifPresentOrElse(
couple -> {
if (couple.isRecoupleReceiver(oAuth2Member.getMemberId())) {
log.debug("send code!!");
sendRecoupleCode(response, coupleId, tokenDto.accessToken());
} else {
sendCode(response, tokenDto.accessToken());
}
}
, () -> sendCode(response, tokenDto.accessToken())
);
}
private void sendRecoupleCode(HttpServletResponse response, Long coupleId, String accessToken) {
String recoupleUrl = redirectUrl + "recouple/" + coupleId;
response.setStatus(HttpServletResponse.SC_MOVED_PERMANENTLY);
response.setHeader("Location", redirectUrl);
response.setHeader("recouple-url", recoupleUrl);
try {
response.sendRedirect(redirectUrl + "?token=" + accessToken + "&recouple-url=" + recoupleUrl);
} catch (IOException e) {
throw new IllegalStateException("Something went wrong while generating response message", e);
}
}
private void sendCode(HttpServletResponse response, String accessToken) {
response.setStatus(HttpServletResponse.SC_MOVED_PERMANENTLY);
response.setHeader("Location", redirectUrl);
try {
response.sendRedirect(redirectUrl +"?token=" + accessToken);
} catch (IOException e) {
throw new IllegalStateException("Something went wrong while generating response message", e);
}
}
}
onAuthenticationSuccess
메서드가 호출된다. Authentication
: 현재 인증된 사용자의 정보를 나타낸다. Principal, Credentials, Authorities등 여러 정보를 포함한다.Principal
: 인증된 사용자를 나타낸다. 사용자의 핵심 정보를 담고 있다. -> MyOAuth2Member
package com.lovely4k.backend.authentication.token;
import com.lovely4k.backend.authentication.RefreshToken;
import com.lovely4k.backend.authentication.RefreshTokenRepository;
import com.lovely4k.backend.member.Member;
import io.jsonwebtoken.*;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.security.Key;
import java.util.Date;
import java.util.Optional;
@Slf4j
@Component
public class TokenProvider {
private static final String AUTHORITIES_KEY = "auth";
private static final String BEARER_PREFIX = "Bearer ";
private static final int ACCESS_TOKEN_EXPIRE_TIME = 1000 * 60 * 30; //30분
private static final int REFRESH_TOKEN_EXPIRE_TIME = 1000 * 60 * 60 * 24 * 7; //7일
private final Key key;
private final RefreshTokenRepository refreshTokenRepository;
public TokenProvider(@Value("${jwt.secret}") String secretKey,
RefreshTokenRepository refreshTokenRepository) {
this.refreshTokenRepository = refreshTokenRepository;
byte[] keyBytes = Decoders.BASE64.decode(secretKey);
this.key = Keys.hmacShaKeyFor(keyBytes);
}
@Transactional
public TokenDto generateTokenDto(Member member) {
long now = (new Date().getTime());
Date accessTokenExpiresIn = new Date(now + ACCESS_TOKEN_EXPIRE_TIME);
String accessToken = Jwts.builder()
.setSubject(member.getEmail())
.claim(AUTHORITIES_KEY, member.getRole().toString())
.setExpiration(accessTokenExpiresIn)
.signWith(key, SignatureAlgorithm.HS256)
.compact();
String refreshToken = Jwts.builder()
.setExpiration(new Date(now + REFRESH_TOKEN_EXPIRE_TIME))
.signWith(key, SignatureAlgorithm.HS256)
.compact();
RefreshToken refreshTokenObject = RefreshToken.builder()
.id(String.valueOf(member.getId()))
.member(member)
.keyValue(refreshToken)
.build();
refreshTokenRepository.save(refreshTokenObject);
return new TokenDto(BEARER_PREFIX, accessToken, refreshToken, accessTokenExpiresIn.getTime());
}
public boolean validateToken(String token) {
try {
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
return true;
} catch (SecurityException | MalformedJwtException e) {
log.info("Invalid JWT signature, 유효하지 않는 JWT 서명 입니다.");
} catch (ExpiredJwtException e) {
log.info("Expired JWT token, 만료된 JWT token 입니다.");
} catch (UnsupportedJwtException e) {
log.info("Unsupported JWT token, 지원되지 않는 JWT 토큰 입니다.");
} catch (IllegalArgumentException e) {
log.info("JWT claims is empty, 잘못된 JWT 토큰 입니다.");
}
return false;
}
@Transactional(readOnly = true)
public RefreshToken isPresentRefreshToken(Member member) {
Optional<RefreshToken> optionalRefreshToken = refreshTokenRepository.findByMember(member);
return optionalRefreshToken.orElse(null);
}
}
String accessToken = Jwts.builder()
.setSubject(member.getEmail())
.claim(AUTHORITIES_KEY, member.getRole().toString())
.setExpiration(accessTokenExpiresIn)
.signWith(key, SignatureAlgorithm.HS256)
.compact();
HS256
알고리즘을 통해 서명한다.Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
에서 JWT를 파싱하고 검증하는 과정에서 여러 단계를 거친다.
expiration time
이 포함되어 있으며, 이는 토큰 만료 시간을 나타낸다.http.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);
JWT Filter
를 UsernamePasswordAuthenticationFilter
이전에 배치.JWT (JSON Web Token) 필터는 웹 애플리케이션에서 인증 및 권한 부여 과정을 관리하기 위해 사용되는 중요한 컴포넌트입니다. 이 필터는 들어오는 요청의 헤더에서 JWT를 추출하고 검증하여 사용자가 요청한 자원에 접근할 수 있는지 여부를 결정합니다.
JWT 추출 및 파싱 : 필터는 일반적으로 HTTP 요청 헤더에서 Authorization
필드를 검사하여 JWT를 추출한다. Bearer [token]
형식으로 제공되는 경우가 많으며 필터는 이 토큰을 파싱하여 JWT로 변환한다.
JWT 검증 : JWT는 디지털 서명을 통해 보호된다. 필터는 JWT의 서명을 검증하여 토큰의 무결성과 유효성을 확인한다. 서명 검증 실패시 요청은 거부된다.
클레임 처리 : JWT에는 사용자의 식별 정보 및 권한 정보(예: 사용자 ID, 역할)
가 클레임 형태로 저장되어 있다. 필터는 이 클레임들을 읽고 처리하여 사용자의 인증 및 권한을 결정한다.
package com.lovely4k.backend.authentication.token;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.lovely4k.backend.authentication.RefreshToken;
import com.lovely4k.backend.authentication.exception.InvalidateTokenResponseWriter;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.security.Key;
import java.util.Arrays;
import java.util.Collection;
import java.util.Map;
@Slf4j
@RequiredArgsConstructor
@Component
public class JwtFilter extends OncePerRequestFilter {
public static String AUTHORIZATION_HEADER = "Authorization"; // NOSONAR
public static String BEARER_PREFIX = "Bearer "; // NOSONAR
public static String AUTHORITIES_KEY = "auth"; // NOSONAR
private static final String REFRESH_HEADER = "Refresh-Token";
private final TokenProvider tokenProvider;
private final UserDetailsServiceImpl userDetailsService;
private final ObjectMapper objectMapper;
@Value("${jwt.secret}")
private String SECRET_KEY; // NOSONAR
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws IOException, ServletException {
String refreshKey = resolveRefresh(request);
if (StringUtils.hasText(refreshKey)) {
sendAccessToken(response, refreshKey);
return;
}
String jwt = resolveToken(request);
if (StringUtils.hasText(jwt)) {
try {
byte[] keyBytes = Decoders.BASE64.decode(SECRET_KEY);
Key key = Keys.hmacShaKeyFor(keyBytes);
InvalidateTokenResponseWriter.write(key, jwt, response, objectMapper); //바디에 잘못된 토큰 쓰기
log.debug("if 분기문 안 로직 ");
Claims claims = resolveClaims(jwt); //jwt 토큰으로부터 Claim을 얻어 옴 Claim 내에는 사용자의 email이 들어있음
updateSecurityContext(claims, jwt); //claim에 들어있는 email로 멤버를 조회해서 SecurityContext에 Member 넣기
} catch (Exception e) {
return; //예외는 InvalidateTokenResponseWriter.write(key, jwt, response, objectMapper); 여기서 상세 메시지 바디에 쓰고 예외 던짐
}
}
filterChain.doFilter(request, response);
}
private void updateSecurityContext(Claims claims, String jwt) {
String subject = claims.getSubject();
Collection<? extends GrantedAuthority> authorities =
Arrays.stream(claims.get(AUTHORITIES_KEY).toString().split(","))
.map(SimpleGrantedAuthority::new)
.toList();
log.debug("subject = " + subject);
UserDetails principal = userDetailsService.loadUserByUsername(subject);
log.debug("principal = " + principal);
Authentication authentication = new UsernamePasswordAuthenticationToken(principal, jwt, authorities);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
private Claims resolveClaims(String jwt) {
byte[] keyBytes = Decoders.BASE64.decode(SECRET_KEY);
Key key = Keys.hmacShaKeyFor(keyBytes);
Claims claims;
try {
claims = Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(jwt).getBody();
} catch (ExpiredJwtException e) {
log.debug("토큰 만료 예외 clamims: {}", e.getClaims());
claims = e.getClaims();
}
return claims;
}
private void sendAccessToken(HttpServletResponse response, String refreshKey) throws IOException {
RefreshToken refreshToken = tokenProvider.findRefreshTokenByKeyValue(refreshKey);
String jwt = tokenProvider.generateAccessToken(refreshToken.getMember());
response.setContentType("application/json;charset=UTF-8");
response.setStatus(HttpServletResponse.SC_CREATED);
response.getWriter().println(
objectMapper.writeValueAsString(
Map.of("accessToken", jwt)
)
);
log.debug("업데이트 된 jwt: {}", jwt);
}
private String resolveToken(HttpServletRequest request) {
log.debug("인증 헤더: {}", request.getHeader(AUTHORIZATION_HEADER));
String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) {
return bearerToken.substring(7);
}
return null;
}
private String resolveRefresh(HttpServletRequest request) {
log.debug("리프레시 토큰 헤더: {}", request.getHeader(REFRESH_HEADER));
String refreshToken = request.getHeader(REFRESH_HEADER);
if (StringUtils.hasText(refreshToken)) {
return refreshToken;
}
return null;
}
}
try {
claims = Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(jwt).getBody();
} catch (ExpiredJwtException e) {
claims = e.getClaims();
}
email
.UsernamePasswordAuthenticationToken
은 인증 정보를 담는 객체이다. 사용자 세부 정보, JWT, 사용자 권한을 이용하여 객체를 생성한다.SecurityContxtHolder
의 컨텍스트에 authentication
객체를 설정한다. 이렇게 하면 현재 요청을 수행중인 사용자가 인증된 것으로 처리된다. Spring Security는 이 정보를 사용하여 사용자의 권한을 확인하고, 해당 사용자가 요청하는 리소스에 접근할 수 있는지 결정한다.byte[] keyBytes = Decoders.BASE64.decode(SECRET_KEY);
Key key = Keys.hmacShaKeyFor(keyBytes);
위 코드는 JWT 생성 및 검증에 사용되는 비밀 키를 설정하는 과정이다. 이 과정은 JWT의 디지털 서명을 생성하고 검증하는 데 필요하다.
SECRET_KEY
를 디코딩하여 바이트 배열로 변환한다. JWT의 서명에 사용되는 키는 바이트 배열로 제공되어야 한다.
Keys.hmacShaKeyFor(keyBytes); : 주어진 바이트 배열을 사용하여 HMAC SHA 알고리즘을 위한 Key
객체를 생성한다.
위 과정을 통해 생성된 Key 객체는 JWT를 생성하거나 파싱할 때 signWith 메서드에 전달되어, 토큰의 서명 생성 및 검증에 사용된다. 예를 들어, JJWT 라이브러리를 사용하여 JWT를 생성할 때, 이 키를 사용하여 서명을 생성하고, 토큰을 검증할 때도 동일한 키를 사용하여 서명의 유효성을 확인한다.