Spring Security OAuth2 클라이언트 JWT
예제 목표
- 구현 방식
- 인증 : 네이버/구글 소셜 로그인 (코드 방식) 후 JWT 발급
- 인가 : JWT를 통한 경로별 접근 권한
- 인증 정보 DB 저장 후 추가 정보 기입
- 버전 및 의존성
- Spring boot 3.xx
- Spring Security 6.2.2
- OAuth2 Client
- JWT 0.12.3 등
- OAuth2.0 코드 방식 인증을 활용하고 JWT는 단일 토큰으로 진행
- 관리를 하지 않고 인증만 받고 사용하는 경우 추가적인 사용자 정보나 어떠한 사용자가 우리의 서비스를 활용하는지 확인할 수 없기에 소셜 로그인을 통해 인증 받은 데이터는 DB에 저장을 한 뒤 관리해야 한다.
- 예제에서 사용되는 JWT는 2가지
- 백엔드 <-> 소셜 로그인 제공 서비스(구글, 네이버)
- OAuth2로 소셜 로그인을 진행하면 로그인 제공 서비스에서 백엔드에게 JWT를 발급
-> 백엔드는 발급 받은 JWT로 다시 서비스에 유저 정보를 요청
- OAuth2로 소셜 로그인을 진행하면 로그인 제공 서비스에서 백엔드에게 JWT를 발급
- 백엔드 <-> 프론트
- 백엔드 <-> 소셜 로그인 제공 서비스(구글, 네이버)
동작 원리 및 책임 분배
- OAuth2 Code Grant 방식의 동작 순서
- 로그인 페이지
- 성공 후 코드 발급 (redirect_url)
- 코드를 통해 Access 토큰 요청
- Access 토큰 발급 완료
- Access 토큰을 통해 유저 정보 요청
- 유저 정보 획득 완료
- JWT 방식 OAuth2 클라이언트 고려해야 할 점
- 로그인(인증)이 성공하면 JWT를 발급해야 하는 문제
- 로그인 경로에 하이퍼링크로 소셜 로그인 창을 띄우고 로그인 로직이 수행
- JWT는 stateless 상태로 관리 -> 로그인 성공 시 하이퍼링크로 실행했기 때문에 JWT를 받을 로직이 없다는 문제가 발생
- API client(axios, fetch)로 요청을 보내는 경우에 백엔드 측으로 요청이 전송되지만 외부 서비스 로그인 페이지를 확인할 수 없기에 하이퍼링크를 사용한다.
- API client(axios, fetch)로 요청을 보내는 경우에 백엔드 측으로 요청이 전송되지만 외부 서비스 로그인 페이지를 확인할 수 없기에 하이퍼링크를 사용한다.
- 웹/하이브리드/네이티브앱별 특징으로 인한 문제
- ex) 앱 환경에서 쿠키 소멸 현상 등..
- 로그인(인증)이 성공하면 JWT를 발급해야 하는 문제
프론트 / 백엔드 책임 분배
- 모든 책임을 프론트가 맡는 경우
- 프론트단에서 로그인 요청 -> 코드 요청 및 발급 -> Access 토큰 요청 및 발급 -> 유저 정보 발급 등 대부분의 과정을 프론트가 수행
백엔드단에서는 전달받은 유저 정보 확인 후 JWT를 발급하는 방식- 프론트에서 보낸 유저 정보의 진위 여부를 따지기 위한 추가적인 보안 로직이 필요하다.
- 프론트에서 보낸 유저 정보의 진위 여부를 따지기 위한 추가적인 보안 로직이 필요하다.
- 프론트단에서 로그인 요청 -> 코드 요청 및 발급 -> Access 토큰 요청 및 발급 -> 유저 정보 발급 등 대부분의 과정을 프론트가 수행
- 책임을 프론트와 백엔드가 나눠 맡는 경우 => 잘못된 방식(크게 2가지)
- 프론트단에서 로그인 요청 -> 코드 요청 및 발급 후 코드를 백엔드로 전송
백엔드단에서 전달 받은 코드로 Access 토큰 발급 요청 및 발급 -> 유저 정보 획득 -> 유저 정보 확인 후 JWT 발급하는 방식 - 프론트단에서 로그인 요청 -> 코드 요청 및 발급 -> Access 토큰 요청 및 발급 후 Access 토큰을 백엔드로 전송
백엔드단에서 전달 받은 Access 토큰으로 유저 정보 획득 -> 유저 정보 확인 후 JWT 발급하는 방식 - 책임을 나눠 맡는 방식이 잘못된 이유
- 카카오와 같은 대형 서비스 개발 포럼 및 보안 규격을 보면 코드 / Access 토큰을 전송하는 방법을 지양한다.
- 프론트단에서 로그인 요청 -> 코드 요청 및 발급 후 코드를 백엔드로 전송
- 모든 책임을 백엔드가 맡는 경우
- 프론트단에서 백엔드의 OAuth2 로그인 경로로 하이퍼링킹을 진행
백엔드단에서 로그인 페이지 요청 -> 코드 요청 및 발급 -> Access 토큰 요청 및 발급 -> 유저 정보 획득 -> 유저 정보 확인 후 JWT 발급하는 방식- 주로 웹앱 / 모바일앱 통합 환경 서버에서 사용
- 백엔드에서 JWT를 발급하는 방식의 고민과 프론트측에서 받는 로직을 처리해야 한다.
- 예제는 모든 책임을 백엔드가 맡는 경우로 진행
- 프론트단에서 백엔드의 OAuth2 로그인 경로로 하이퍼링킹을 진행
- JWT 방식 OAuth2 클라이언트를 구현할 때는 책임을 나누지말고 어느 한쪽이 모든 책임을 맡아야 한다!!
의존성 추가
- 데이터베이스 의존성 주석 처리
- 스프링 부트에서 데이터베이스 의존성을 추가한 뒤 연결을 진행하지 않을 경우 런타임 에러 발생할 수 있기에 임시로 주석 처리해줄 것!
- JWT 의존성
- JWT 생성 / 관리를 위해 JWT 의존성은 필수적으로 추가
- 버전마다 메소드가 많이 다르기에 그때 그때 참고할 것 (예제는 0.12.3 버전 사용)
dependencies {
implementation 'io.jsonwebtoken:jjwt-api:0.12.3'
implementation 'io.jsonwebtoken:jjwt-impl:0.12.3'
implementation 'io.jsonwebtoken:jjwt-jackson:0.12.3'
}
동작 원리
- 각 필터가 동작하는 주소 (관습적으로 사용하는 주소)
- OAuth2AuthorizationRequestRedirectFilter: /oauth2/authorization/서비스명
- 소셜 로그인 시도하는 url
- ex) /oauth2/authorization/google
- OAuth2LoginAuthenticationFilter : /login/oauth2/code/서비스
- 외부 인증 서버에 설정할 redirect_uri로 로그인 성공 시 리다이렉트 되는 토큰 발급을 위해 코드 발급하는 url
- JWTFilter : 발급해준 JWT를 검증하는 필터로 모든 주소에서 동작
- 직접 커스텀해서 등록해야한다.
- OAuth2AuthorizationRequestRedirectFilter: /oauth2/authorization/서비스명
- 대부분의 필터들은 자동으로 등록되며 직접 구현해야할 부분이 존재한다.
- OAuth2 클라이언트에서 직접 구현해줘야 하는 부분
- OAuth2UserDetailsService: 토큰을 통해 유저 정보를 받는 역할
- OAuth2UserDetails: 토큰을 통해 유저 정보를 받는 역할
- LoginSuccessHandler: 로그인 성공 핸들러 역할
- JWT에서 직접 구현해줘야 하는 부분
- JWTFilter: 발급해준 JWT를 검증하는 역할
- JWTUtil: JWT를 발급 및 검증하는 역할
OAuth2 변수 역할 및 설정
#registration
spring.security.oauth2.client.registration.서비스명.client-name=서비스명
spring.security.oauth2.client.registration.서비스명.client-id=서비스에서 발급 받은 아이디
spring.security.oauth2.client.registration.서비스명.client-secret=서비스에서 발급 받은 비밀번호
spring.security.oauth2.client.registration.서비스명.redirect-uri=서비스에 등록한 우리쪽 로그인 성공 URI
spring.security.oauth2.client.registration.서비스명.authorization-grant-type=authorization_code
spring.security.oauth2.client.registration.서비스명.scope=리소스 서버에서 가져올 데이터 범위
#provider
spring.security.oauth2.client.provider.서비스명.authorization-uri=서비스 로그인 창 주소
spring.security.oauth2.client.provider.서비스명.token-uri=토큰 발급 서버 주소
spring.security.oauth2.client.provider.서비스명.user-info-uri=사용자 정보 획득 주소
spring.security.oauth2.client.provider.서비스명.user-name-attribute=응답 데이터 변수
- application.properties OAuth2 변수 설정
- registration : 외부 서비스에서 해당 서비스를 특정하기 위해 등록하는 정보이기 때문에 필수적으로 등록해야한다.
- 서비스 명에 naver, google 등을 적어주면 된다.
- redirect-uri : 로그인 성공 시 코드를 발급해주는 uri로 관례적으로 /login/oauth2/code/서비스명로 작성한다.
- provider : 서비스별로 정해진 값이 존재하며 OAuth2 클라이언트 의존성이 유명한 서비스의 경우 내부적으로 데이터를 가지고 있다.
- ex) 구글, Okta, 페이스북, 깃허브 등등..
- registration : 외부 서비스에서 해당 서비스를 특정하기 위해 등록하는 정보이기 때문에 필수적으로 등록해야한다.
#registration
spring.security.oauth2.client.registration.naver.client-name=naver
spring.security.oauth2.client.registration.naver.client-id=발급아이디
spring.security.oauth2.client.registration.naver.client-secret=발급비밀번호
spring.security.oauth2.client.registration.naver.redirect-uri=http://localhost:8080/login/oauth2/code/naver
spring.security.oauth2.client.registration.naver.authorization-grant-type=authorization_code
spring.security.oauth2.client.registration.naver.scope=name,email
#provider
spring.security.oauth2.client.provider.naver.authorization-uri=https://nid.naver.com/oauth2.0/authorize
spring.security.oauth2.client.provider.naver.token-uri=https://nid.naver.com/oauth2.0/token
spring.security.oauth2.client.provider.naver.user-info-uri=https://openapi.naver.com/v1/nid/me
spring.security.oauth2.client.provider.naver.user-name-attribute=response
- application.properties OAuth2 변수 설정 예시
SecurityConfig 등록
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
//CSRF disable
http
.csrf((auth) -> auth.disable());
//Form 로그인 방식 disable
http
.formLogin((auth) -> auth.disable());
//HTTP Basic 인증 방식 disable
http
.httpBasic((auth) -> auth.disable());
//OAuth2 기본 설정
http
.oauth2Login(Customizer.withDefaults());
//경로별 인가 작업
http
.authorizeHttpRequests((auth) -> auth
.requestMatchers("/").permitAll()
.anyRequest().authenticated());
//세션 설정 : STATELESS
http
.sessionManagement((session) -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
return http.build();
}
}
- Spring Security 설정 클래스
- @EnableWebSecurity : Spring Security를 활성화하는 역할로 FilterChainProxy가 등록되어 HTTP 요청을 가로채고 보안 검사를 수행하게 된다.
- 예제에서는 JWT를 사용하기 때문에 CSRF, Form 로그인, HTTP Basic 인증 설정을 모두 꺼놨다.
- authorizeHttpRequests() : HTTP 요청의 인증 및 인가 규칙 설정 메소드
- 특정 URL 패턴에 대한 접근 제어 가능
- 예제에서는 /에 해당되는 홈 화면만 모든 사용자가 접근할 수 있고 나머지 모든 요청은 인증된 사용자만 접근 가능하도록 설정
- sessionManagement(sessionManagement((session) -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))) : 세션을 Stateless하도록 설정
네이버 소셜 로그인 신청
#naver
#registration
spring.security.oauth2.client.registration.naver.client-name=naver
spring.security.oauth2.client.registration.naver.client-id=
spring.security.oauth2.client.registration.naver.client-secret=
spring.security.oauth2.client.registration.naver.redirect-uri=http://localhost:8080/login/oauth2/code/naver
spring.security.oauth2.client.registration.naver.authorization-grant-type=authorization_code
spring.security.oauth2.client.registration.naver.scope=name,email
#provider
spring.security.oauth2.client.provider.naver.authorization-uri=https://nid.naver.com/oauth2.0/authorize
spring.security.oauth2.client.provider.naver.token-uri=https://nid.naver.com/oauth2.0/token
spring.security.oauth2.client.provider.naver.user-info-uri=https://openapi.naver.com/v1/nid/me
spring.security.oauth2.client.provider.naver.user-name-attribute=response
- naver OAuth2 변수 설정
- client-id, client-secret에 각각 로그인 api를 신청해서 얻은 ID 값과 Secret 값을 넣어주면 된다.
구글 소셜 로그인 신청
- 구글 소셜 로그인 api 신청 사이트
- 구글 OAuth2 소셜 로그인을 사용하기 위해서 구글 클라우드 플랫폼(GCP) 가입이 필수적
- 신청 방법
- 프로젝트 생성 -> 검색 -> 사용자 인증 정보(API 및 서비스) -> 좌측에서 OAuth 동의 화면 구성
- 데이터 액세스 -> 데이터 범위 설정
- 사용자 인증 정보 만들기 -> OAuth 클라이언트 ID
#registration
spring.security.oauth2.client.registration.google.client-name=google
spring.security.oauth2.client.registration.google.client-id=
spring.security.oauth2.client.registration.google.client-secret=
spring.security.oauth2.client.registration.google.redirect-uri=http://localhost:8080/login/oauth2/code/google
spring.security.oauth2.client.registration.google.authorization-grant-type=authorization_code
spring.security.oauth2.client.registration.google.scope=profile,email
- Google OAuth2 변수 설정
- ID, Secret에 값을 넣어주면 된다.
- Provider 값은 별도로 설정하지 않아도 된다.
- 문제 발생 : Github에 OAuth2 Naver, Google 변수 설정 후 push했는데 오류 발생
- 이유 : GitHub의 Push Protection이 OAuth Client ID 및 Secret을 감지하여 푸시를 차단
- 해결 방법 : application.properties에서 OAuth 키를 삭제하고 Git의 기록에서 제거한 후 다시 푸시
한번 깃에 커밋한 이상 기록에 남아 있더라도 푸시가 차단되기에 reset, rebase를 사용해서 완전히 제거 후 푸시해야 한다.- 최초로 OAuth2 변수 설정 시 ID와 Secret이 포함된 커밋 찾기
- 바로 이전 커밋만 수정하는 경우 reset을 사용해도 되지만 아닌 경우 rebase -i를 사용
- rebase -i HEAD~N 으로 최근 N개의 커밋을 수정
- 입력한 수 만큼의 커밋들이 pick jkl1122 Initial commit 이런 형태로 나온다.
- 수정할 커밋들을 pick 대신 edit으로 바꿔주기
- git add . 로 수정된 파일 Git에 추가
- git commit --amend --no-edit로 커밋 덮어쓰기
- git rebase --continue로 리베이스 진행
- git reflog expire --expire=now --all
git gc --prune=now 로 Git의 캐시된 기록을 정리 - 이후 git push origin feat/login_hb --force로 원격 저장소에 강제 푸쉬
- 이유 : GitHub의 Push Protection이 OAuth Client ID 및 Secret을 감지하여 푸시를 차단
- OAuth 키, DB 패스워드, API 키 같은 민감한 정보는 깃에 포함되면 절대 절대 안된다!!
- application.yml 파일에 민감한 정보가 있다면 .gitignore에 추가하기
- application.yml 파일에 민감한 정보를 직접 작성 X
- 환경 변수를 사용하거나 GitHub Actions의 Secret 관리를 사용
운영 환경에서는 AWS Parameter Store, HashiCorp Vault 같은 보안 저장소를 활용하는 것이 가장 좋다.
- 환경 변수를 사용하거나 GitHub Actions의 Secret 관리를 사용
ㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡapplication.yml 파일 예시ㅡㅡㅡㅡㅡㅡㅡ
spring:
security:
oauth2:
client:
registration:
naver:
client-name: naver
client-id: "${NAVER_CLIENT_ID}"
client-secret: "${NAVER_CLIENT_SECRET}"
redirect-uri: http://localhost:8080/login/oauth2/code/naver
authorization-grant-type: authorization_code
scope:
- name
- email
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
ㅡㅡㅡㅡㅡㅡㅡㅡGithub Actions YAML 예시 ㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡ
- name: Create application.yml
run: |
mkdir -p src/main/resources
cat <<EOF > src/main/resources/application.yml
spring:
security:
oauth2:
client:
registration:
naver:
client-name: naver
client-id: "${{ secrets.NAVER_CLIENT_ID }}"
client-secret: "${{ secrets.NAVER_CLIENT_SECRET }}"
- application.yml, Github Action YAML 예시
- application.yml 파일을 깃에 올리지 말거나 올리는 경우 id, secret 등 민감한 정보는 파일에 직접 작성하면 안된다.
- GitHub Actions의 Secret 관리를 사용 방법
- github Action Secret에 변수 등록
- ex) NAME : DB_USERNAME , Secret : Admin
- .github/workflows/ 폴더 안에 있는 자동화 스크립트 파일(GitHub Actions YAML 파일)에서 Secret 값을 받도록 환경 변수 설정
- ex) ${{ secrets.DB_USERNAME }}
- Spring Boot가 Github Actions YAML 파일에 설정된 환경 변수를 읽어서 application.yml 파일 변수에 적용
- ex) ${DB_USERNAME}
- github Action Secret에 변수 등록
OAuth2UserService / DTO 구현
- 네이버와 구글의 응답이 서로 다르기에 다른 방식으로 데이터를 받아야한다.
- 네이버 응답 : JSON 응답방식, response라는 키 내부에 id, name등 필요한 정보들이 담겨있다.
ex) { resultcode=00, message=success, response={id=123123123, name=개발자유미} } - 구글 응답 : JSON 응답방식, 외부에 필요 정보 id, name 등이 담겨 있다.
ex) { resultcode=00, message=success, id=123123123, name=개발자유미 }
- 네이버 응답 : JSON 응답방식, response라는 키 내부에 id, name등 필요한 정보들이 담겨있다.
public interface OAuth2Response {
//제공자 (Ex. naver, google, ...)
String getProvider();
//제공자에서 발급해주는 아이디(번호)
String getProviderId();
//이메일
String getEmail();
//사용자 실명 (설정한 이름)
String getName();
}
- 응답 DTO 인터페이스
- 네이버, 구글 등 서비스마다 응답 데이터 형태가 조금씩 다르기에 인터페이스로 추상화
public class NaverResponse implements OAuth2Response {
private final Map<String, Object> attribute;
public NaverResponse(Map<String, Object> attribute) {
this.attribute = (Map<String, Object>) attribute.get("response");
}
@Override
public String getProvider() {
return "naver";
}
@Override
public String getProviderId() {
return attribute.get("id").toString();
}
@Override
public String getEmail() {
return attribute.get("email").toString();
}
@Override
public String getName() {
return attribute.get("name").toString();
}
}
- 응답 DTO 인터페이스를 구현한 Naver 응답 DTO
- 데이터 응답 타입이 JSON이기 때문에 Map 타입 변수에 담는다.
- Naver는 id, email, name 등 필요한 정보는 response라는 내부 키 안에 들어있기에 get("response")로 꺼내준다.
@RequiredArgsConstructor
public class GoogleResponse implements OAuth2Response {
private final Map<String, Object> attribute;
@Override
public String getProvider() {
return "google";
}
@Override
public String getProviderId() {
return attribute.get("sub").toString();
}
@Override
public String getEmail() {
return attribute.get("email").toString();
}
@Override
public String getName() {
return attribute.get("name").toString();
}
}
- 응답 DTO 인터페이스를 구현한 Google 응답 DTO
- Google도 마찬가지로 데이터 응답 타입이 JSON 타입이다.
- id, name, email 등 필요한 정보는 외부에 있으며 id 값은 sub 키의 값이다.
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final OAuth2UserService oAuth2UserService;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
//나머지 생략
//OAuth2 기본 설정
http
.oauth2Login((oath2) -> oath2
.userInfoEndpoint(userInfoEndpointConfig -> userInfoEndpointConfig
.userService(oAuth2UserService)));
}
}
- 기존 SecurityConfig에 내용 추가
- 기존에는 OAuth2 설정에 기본 설정만 되어 있었기에 직접 만든 OAuth2UserService 빈을 등록
public record UserDto(String role,
String name,
String username) {
}
- userDto 구현
- userDto는 record 타입으로 구현
- record: 특별한 종류의 클래스로, 불변 객체를 쉽게 만들 수 있도록 설계되었다.
- record 클래스를 정의하면, 필드, 생성자, toString(), equals(), hashCode() 메서드가 자동으로 생성된다.
- 필드는 기본적으로 final이기에 불변으로 스레드 안정이 보장된다.
- 단점: 다른 클래스를 상속하지 못한다.
@RequiredArgsConstructor
public class CustomOAuth2User implements OAuth2User {
private final UserDto userDto;
//네이버, 구글 등 서비스 별 응답 값 형태가 서로 다르기에 획일화하기 어려워 사용하지 않을 예정
@Override
public Map<String, Object> getAttributes() {
return null;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
Collection<GrantedAuthority> collection = new ArrayList<>();
collection.add(new GrantedAuthority() {
@Override
public String getAuthority() {
return userDto.role();
}
});
return collection;
}
@Override
public String getName() {
return userDto.name();
}
public String getUsername() {
return userDto.username();
}
}
- CustomOAuth2User 구현
- OAuth2User 인터페이스를 구현
- 서비스 별로 응답 데이터 형태가 다르기에 획일화하기 어려운 관계로 예제에서는 getAttribute()는 사용하지 않았다.
@Slf4j
@Service
public class OAuth2UserService extends DefaultOAuth2UserService {
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
OAuth2User oAuth2User = super.loadUser(userRequest);
log.info("oAuth2UserInfo = {}", oAuth2User);
String registrationId = userRequest.getClientRegistration().getRegistrationId();
OAuth2Response oAuth2Response = null;
if (registrationId.equals("naver")) {
oAuth2Response = new NaverResponse(oAuth2User.getAttributes());
} else if (registrationId.equals("google")) {
oAuth2Response = new GoogleResponse(oAuth2User.getAttributes());
} else {
return null;
}
String username = oAuth2Response.getProvider() + " " + oAuth2Response.getProviderId();
//role은 임시적으로 임의값 대입
UserDto userDto = new UserDto("ROLE_USER", oAuth2Response.getName(), username);
return new CustomOAuth2User(userDto);
}
}
- OAuth2UserService 구현
- OAuth2UserService를 구현하기 위해서 DefaultOAuth2UserService 클래스를 상속받아서 구현한다.
- OAuth2UserRequest : 리소스 서버에서 제공되는 유저 정보를 담고있는 변수
- 사용자 고유 아이디 값을 만들기 위해 리소스 서버에서 발급 받은 정보로 provider와 providerId를 통해 username을 만들었다.
유저 정보 DB 저장
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://${LOCAL_DB_HOST}/DontTouchMe?serverTimezone=UTC&characterEncoding=UTF-8
username: ${LOCAL_DB_USERNAME}
password: ${LOCAL_DB_PASSWORD}
jpa:
hibernate:
ddl-auto: update
naming:
physical-strategy: org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl
- application.yml DB 설정
- application.yml 파일을 깃에 올리는 경우 DB 설정도 마찬가지로 중요한 정보는 제외하고 올려야 한다.
- username, DB host, password는 직접 작성하지 말 것
- application.yml 파일을 깃에 올리는 경우 DB 설정도 마찬가지로 중요한 정보는 제외하고 올려야 한다.
@Entity
@Getter
@Setter
@NoArgsConstructor
public class UserEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String username;
private String name;
private String email;
private String role;
public UserEntity(String username, String name, String email, String role) {
this.username = username;
this.name = name;
this.email = email;
this.role = role;
}
}
- UserEntity 구현
public interface UserRepository extends JpaRepository<UserEntity, Long> {
UserEntity findByUsername(String username);
}
- UserRepository 구현
@Slf4j
@Service
@RequiredArgsConstructor
public class OAuth2UserService extends DefaultOAuth2UserService {
private final UserRepository userRepository;
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
//기존로직과 동일하므로 생략
//추가, 변경된 내용
//DB에 해당 유저 정보가 있는지 확인
if (existData == null) {
//DB에 유저 정보 저장
UserEntity userEntity = new UserEntity(
username,
oAuth2Response.getName(),
oAuth2Response.getEmail(),
"ROLE_USER"
);
userRepository.save(userEntity);
UserDto userDto = new UserDto(
"ROLE_USER",
oAuth2Response.getName(),
username
);
return new CustomOAuth2User(userDto);
} else {
existData.setName(oAuth2Response.getName());
existData.setEmail(oAuth2Response.getEmail());
userRepository.save(existData);
UserDto userDto = new UserDto(
existData.getRole(),
oAuth2Response.getName(),
existData.getUsername()
);
return new CustomOAuth2User(userDto);
}
}
}
- OAuth2UserService 예제 수정 및 추가
- 해당 유저가 DB에 저장되어 있는지 확인 후 없다면 DB에 저장하고 JWT 발급을 위해서 UserDto를 통해 유저 정보를 넘겨주고 DB에 저장된 유저라면 기존 정보를 Update 한 후 UserDto로 넘겨준다.
JWT 발급 및 검증
- JWT는 Header, Payload, Signature 구조로 이루어져 있다.
- Header - JWT임을 명시하고 사용된 암호화 알고리즘 명시
- Payload - 필요한 정보들
- Signature - 암호화 알고리즘을 통해 JWT 발급자를 명시
- 내부 정보(Payload)를 단순 방식으로 인코딩하기에 외부에서 쉽게 디코딩할 수 있다.
- 외부에서 열람해도 되는 정보만 담아야한다.
- JWT 암호화 방식
- 암호화 종류
- 양방향 : 대칭키, 비대칭키
- 단방향
- 암호화 종류
- 예제에서는 양방향 대칭키(HS256) 사용
@Component
public class JwtUtil {
private SecretKey secretKey;
public JwtUtil(@Value("${spring.jwt.secret}") String secret) {
secretKey = new SecretKeySpec(
secret.getBytes(StandardCharsets.UTF_8),
Jwts.SIG.HS256.key().build().getAlgorithm()
);
}
public String getUsername(String token) {
return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("username", String.class);
}
public String getRole(String token) {
return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("role", String.class);
}
public Boolean isExpired(String token) {
return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().getExpiration().before(new Date());
}
public String createJwt(String username, String role, Long expiredMs) {
return Jwts.builder()
.claim("username", username)
.claim("role", role)
.issuedAt(new Date(System.currentTimeMillis()))
.expiration(new Date(System.currentTimeMillis() + expiredMs))
.signWith(secretKey)
.compact();
}
}
- JwtUtils 구현
- 생성자
- @Value 어노테이션을 통해 application.yml 설정 값을 가져올 수 있다.
- createJwt() : username, role, 생성일, 만료일로 JWT를 생성하는 메소드
- 프론트 측에게 넘겨줄 JWT로 서비스에서 제공하는 JWT와는 다른 것
- get~(), is~() : username, role, 만료일을 검증하는 메소드
- 생성자
로그인 성공 JWT 발급
- 목표: OAuth2 로그인 성공 시 실행될 성공 핸들러를 만들고 백엔드 측에서 JWT를 발급받아 프론트 측에 쿠키로 전달
@Component
@RequiredArgsConstructor
public class CustomSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
private final JwtUtil jwtUtil;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
//OAuth2User
CustomOAuth2User customUserDetails = (CustomOAuth2User) authentication.getPrincipal();
String username = customUserDetails.getUsername();
Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
Iterator<? extends GrantedAuthority> iterator = authorities.iterator();
GrantedAuthority auth = iterator.next();
String role = auth.getAuthority();
String token = jwtUtil.createJwt(username, role, 60 * 60 * 60L);
response.addCookie(createCookie("Authorization", token));
//여기에 리다이렉트할 프론트쪽 url
response.sendRedirect("http://localhost:8080");
}
private Cookie createCookie(String key, String value) {
Cookie cookie = new Cookie(key, value);
cookie.setMaxAge(60 * 60 * 60);
// cookie.setSecure(true); //HTTPS에서만 통신
cookie.setPath("/");
cookie.setHttpOnly(true); //자바스크립트가 해당 쿠키를 가져가지 못하게 설정
return cookie;
}
}
- CustomSuccessHandler 구현
- OAuth2 로그인 성공 시 실행될 핸들러이다.
- 백엔드에서 프론트로 JWT 전달하는 과정
- JWT 생성에 필요한 정보를 가져와서 JWT 생성한다.
- 쿠키를 생성 후 생성된 JWT를 담는다.
- 프론트쪽 url로 리다이렉트한다.
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final OAuth2UserService oAuth2UserService;
private final CustomSuccessHandler customSuccessHandler;
private final JwtUtil jwtUtil;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
//이전 내용 생략
http
.oauth2Login((oath2) -> oath2
.userInfoEndpoint(userInfoEndpointConfig -> userInfoEndpointConfig.userService(oAuth2UserService))
.successHandler(customSuccessHandler)
);
return http.build();
}
}
- SecurityConfig 수정
- SuccessHandler와 JwtUtil 빈으로 등록
- OAuth2 설정에서 SuccessHandler로 만든 CustomSuccessHandler 등록
JWT 검증 필터
- 목표 :스프링 시큐리티 filter chain 요청에 담긴 JWT를 검증하기 위한 커스텀 필터 등록
@RequiredArgsConstructor
@Slf4j
public class JwtFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String authorization = null;
Cookie[] cookies = request.getCookies();
for (Cookie cookie : cookies) {
if (cookie.getName().equals("Authorization")) {
authorization = cookie.getValue();
}
}
//Authorization 헤더 검증
if (authorization == null) {
log.info("token null");
filterChain.doFilter(request, response);
//조건이 해당되면 메소드 종료(필수)
return;
}
//토큰
String token = authorization;
if (jwtUtil.isExpired(token)) {
log.info("token expired");
filterChain.doFilter(request, response);
//조건이 해당되면 메소드 종료(필수)
return;
}
//토큰에서 username과 role 획득
String username = jwtUtil.getUsername(token);
String role = jwtUtil.getRole(token);
//userDto를 생성하여 값 set
UserDto userDto = new UserDto(Role.stringToRole(role), username);
//UserDetails에 회원 정보 객체 담기
CustomOAuth2User customOAuth2User = new CustomOAuth2User(userDto);
//스프링 시큐리티 인증 토큰 생성
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(customOAuth2User, null, customOAuth2User.getAuthorities());
//세션에 사용자 등록
SecurityContextHolder.getContext().setAuthentication(authToken);
filterChain.doFilter(request, response);
}
}
- JwtFilter 구현
- 해당 필터를 통해 요청 쿠키에 JWT가 존재하는 경우 JWT를 검증하고 강제로 SecurityContextHolder에 세션을 생성한다.
- 해당 세션은 Stateless 상태로 관리되기에 요청이 끝나면 소멸된다.
- 해당 필터를 통해 요청 쿠키에 JWT가 존재하는 경우 JWT를 검증하고 강제로 SecurityContextHolder에 세션을 생성한다.
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final OAuth2UserService oAuth2UserService;
private final CustomSuccessHandler customSuccessHandler;
private final JwtUtil jwtUtil;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
//생략
//JwtFilter 추가
//재로그인 시 무한루프 버그 수정
http
.addFilterAfter(new JwtFilter(jwtUtil), OAuth2LoginAuthenticationFilter.class);
return http.build();
}
}
- SecurityConfig 변경
- SecurityFilterChain를 반환하는 메소드안에 예제와 같이 JwtFilter를 등록해주면 된다.
CORS 설정
- CORS (Cross-Origin Resource Sharing) : 웹 브라우저에서 보안 정책 중 하나로, 서로 다른 출처(도메인, 프로토콜, 포트)를 가진 리소스 간의 요청을 허용하거나 차단하는 기능
- 웹 애플리케이션은 API 서버와 프론트엔드 서버가 분리된 경우가 많아서 이를 허용해 주는 방법
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final OAuth2UserService oAuth2UserService;
private final CustomSuccessHandler customSuccessHandler;
private final JwtUtil jwtUtil;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
//CORS 설정
http
.cors(corsCustomizer -> corsCustomizer.configurationSource(new CorsConfigurationSource() {
@Override
public CorsConfiguration getCorsConfiguration(HttpServletRequest request) {
CorsConfiguration configuration = new CorsConfiguration();
//프론트 주소
configuration.setAllowedOrigins(Collections.singletonList("http://localhost:3000"));
//모든 HTTP Method 허용
configuration.setAllowedMethods(Collections.singletonList("*"));
configuration.setAllowCredentials(true);
configuration.setAllowedHeaders(Collections.singletonList("*"));
configuration.setMaxAge(3600L);
//백엔드측에서 프론트측에 데이터를 주는 경우 전달한 쿠키의 JWT 토큰을 노출하는 설정
configuration.setExposedHeaders(Collections.singletonList("Set-Cookie"));
configuration.setExposedHeaders(Collections.singletonList("Authorization"));
return configuration;
}
}));
//생략
return http.build();
}
}
- SecurityConfig CORS 설정 추가
- configuration.setExposedHeaders() : CORS 설정에서, 클라이언트가 응답 헤더 중 특정 헤더를 자유롭게 접근할 수 있도록 허용하는 기능
- 예제에서는 백엔드 측에서 쿠키에 JWT 토큰을 보내고 프론트에서 받을 수 있게 하기 위해서 설정해주는 것
- configuration.setExposedHeaders() : CORS 설정에서, 클라이언트가 응답 헤더 중 특정 헤더를 자유롭게 접근할 수 있도록 허용하는 기능
@Configuration
public class CorsMvcConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.exposedHeaders("Set-Cookie")
.allowedOrigins("http://localhost:3000");
}
}
- CorsMvcConfig 구현
- addCorsMappings() : 특정 URL에 따른 CORS 매핑을 설정하는 기능
기타 참고사항
- JWT OAuth2 쿠키 대신 헤더로 사용하는 방법
- 발급 자체는 쿠키로만 가능
- 헤더로 발급하면 하이퍼 링크로 받을 수 없기 때문
- 첫 발급 이후 헤더로 JWT를 이동시킬 수 있다.
- 로그인 성공 쿠키로 발급
- 프론트의 특정 페이지로 redirct
- 프론트의 특정 페이지는 axios를 통해 쿠키(credentials=true)를 가지고 다시 백엔드로 접근하여 헤더로 JWT를 받아온다.
- 헤더로 받아온 JWT를 로컬 스토리지등에 보관하여 사용
- 발급 자체는 쿠키로만 가능
출처 : [유튜브 개발자 유미]
https://www.youtube.com/watch?v=xsmKOo-sJ3c&list=PLJkjrxxiBSFALedMwcqDw_BPaJ3qqbWeB