JWT 방식에서는 로그인(인증)이 성공하면 JWT 발급 문제와 웹/하이브리드/네이티브앱 방식의 특징에 따라 OAuth2 Code Grant 방식의 책임을 프론트에 둘 것인지 백에 둘 것인지 고민
위와 같은 문제로 OAuth2 Code Grant 동작에 대한 redirect_uri, Access 토큰 발급 문제를 어느 단에서 처리해야 하는지 고민이 많고 잘못된 구현 방법도 많이 있음
프론트에서(로그인 -> Access 토큰 발급 -> 유저 정보 획득) 과정을 수행한 뒤 백엔드에서(유저 정보 확인 -> JWT 발급) 과정을 수행하며 주로 네이티브 앱에서 사용하는 방식
단, 프론트에서 보낸 유저 정보의 진위 여부를 파악하는 추가 로직 필요
책임을 프론트/백이 나눠 가짐: 잘못된 방식
모든 책임을 백엔드가 맡음
프론트에서 백엔드의 OAuth2 경로로 하이퍼 링크를 요청하고 백엔드에서(로그인 페이지 요청 -> 코드 발급 -> Access 토근 -> 유저 정보 획득 -> JWT 발급) 과정을 수행. 주로 웹앱/모바일앱 통합 환경 서버에서 사용
-> 백엔드에서 JWT를 발급하는 방식과 프론트에서 받는 로직 고민 필요
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'
}
@RestController
public class MainController {
@GetMapping("/")
public String mainAPI() {
return "main route";
}
}
@RestController
public class MyController {
@GetMapping("/my")
public String myAPI() {
return "my route";
}
}
모든 주소에서 동작
/oauth2/authorization/서비스명
/oauth2/authorization/naver
/oauth2/authorization/google
/login/oauth2/code/서비스명
/login/oauth2/code/naver
/login/oauth2/code/google
spring:
security:
oauth2:
client:
registration:
서비스명:
client-name: 서비스명
client-id: 서비스에서 발급 받은 아이디
client-secret: 서비스에서 발급 받은 비밀번호
redirect-uri: 서비스에서 등록한 우리쪽 로그인 성공 uri
authorization-grant-type: authorization_code
scope: 리소스 서버에서 가져올 데이터 범위
provider:
서비스명:
authorization-uri: 서비스 로그인 창 주소
token-uri: 토큰 발급 서버 주소
user-info-uri: 사용자 데이터 획득 주소
user-name-attribute: 응답 데이터 변수
@Service
public class CustomOAuth2UserService extends DefaultOAuth2UserService {
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
OAuth2User oAuth2User = super.loadUser(userRequest);
System.out.println(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;
}
//추후 작성
}
}
{
resultcode=00, message=success, response={id=123123123, name=찬이}
}
{
resultcode=00, message=success, id=123123123, name=찬이
}
public interface OAuth2Response {
//제공자 ex) naver, google ...
String getProvider();
//제공자에서 발급해주는 아이디(번호)
String getProviderId();
//이메일
String getEmail();
//사용자 이름
String getName();
}
public class NaverResponse implements OAuth2Response{
private final Map<String, Object> attribute;
public NaverResponse(Map<String, Object> attribute) {
//response 안에 데이터가 감싸져 들어오기 때문
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();
}
}
public class GoogleResponse implements OAuth2Response{
private final Map<String, Object> attribute;
public GoogleResponse(Map<String, Object> attribute) {
this.attribute = 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();
}
}
@Configuration
@EnableWebSecurity
public class SecurityConfig {
private final CustomClientRegistrationRepo customClientRegistrationRepo;
private final CustomOAuth2UserService customOAuth2UserService;
public SecurityConfig(CustomClientRegistrationRepo customClientRegistrationRepo, CustomOAuth2UserService customOAuth2UserService) {
this.customClientRegistrationRepo = customClientRegistrationRepo;
this.customOAuth2UserService = customOAuth2UserService;
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
//csrf disable
http
.csrf((csrf) -> csrf.disable());
//formLogin disable
http
.formLogin((auth) -> auth.disable());
//httpBasic disable
http
.httpBasic((auth) -> auth.disable());
//oauth2 설정
http
.oauth2Login((oauth2) -> oauth2
.clientRegistrationRepository(customClientRegistrationRepo.clientRegistrationRepository())
.userInfoEndpoint((userInfoEndpointConfig) -> userInfoEndpointConfig
.userService(customOAuth2UserService)));
//경로별 인가 작업
http
.authorizeHttpRequests((auth) -> auth
.requestMatchers("/").permitAll()
.anyRequest().authenticated());
//세션 설정 STATELESS
http
.sessionManagement((session) -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
return http.build();
}
}
@Service
public class CustomOAuth2UserService extends DefaultOAuth2UserService {
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
OAuth2User oAuth2User = super.loadUser(userRequest);
System.out.println(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();
UserDto userDto = new UserDto();
userDto.setUsername(username);
userDto.setName(oAuth2Response.getName());
userDto.setRole("ROLE_USER");
return new CustomOAuth2user(userDto);
}
}
@Getter
@Setter
public class UserDto {
private String username;
private String name;
private String role;
}
public class CustomOAuth2user implements OAuth2User {
private final UserDto userDto;
public CustomOAuth2user(UserDto userDto) {
this.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.getRole();
}
});
return collection;
}
@Override
public String getName() {
return userDto.getName();
}
public String getUsername() {
return userDto.getUsername();
}
}
@Entity
@Getter
@Setter
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String username;
private String name;
private String email;
private String role;
}
public interface UserRepository extends JpaRepository<User, Long> {
User findByUsername(String username);
}
@Service
@Transactional(readOnly = true)
public class CustomOAuth2UserService extends DefaultOAuth2UserService {
private final UserRepository userRepository;
public CustomOAuth2UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Transactional
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
OAuth2User oAuth2User = super.loadUser(userRequest);
System.out.println(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();
User findUser = userRepository.findByUsername(username);
if (findUser == null) {
User user = new User();
user.setUsername(username);
user.setName(oAuth2Response.getName());
user.setEmail(oAuth2Response.getEmail());
user.setRole("ROLE_USER");
userRepository.save(user);
UserDto userDto = new UserDto();
userDto.setUsername(username);
userDto.setName(oAuth2Response.getName());
userDto.setRole("ROLE_USER");
return new CustomOAuth2user(userDto);
} else {
findUser.setEmail(oAuth2Response.getEmail());
findUser.setName(oAuth2Response.getName());
UserDto userDto = new UserDto();
userDto.setUsername(findUser.getUsername());
userDto.setName(oAuth2Response.getName());
userDto.setRole(findUser.getRole());
return new CustomOAuth2user(userDto);
}
}
}
@Component
public class JwtUtil {
private SecretKey secretKey;
public JwtUtil(@Value("${spring.jwt.secret}") String secretKey) {
this.secretKey = new SecretKeySpec(secretKey.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();
}
}
@Component
public class CustomSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
private final JwtUtil jwtUtil;
public CustomSuccessHandler(JwtUtil jwtUtil) {
this.jwtUtil = jwtUtil;
}
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
//OAuth2User
CustomOAuth2user customUserDetails = (CustomOAuth2user) authentication.getPrincipal();
//JWT 발급할 때 username과 role 값을 넣기로 설정했기 때문에 추출해야함
//username 추출
String username = customUserDetails.getUsername();
//role 추출
Collection<? extends GrantedAuthority> authorities = customUserDetails.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));
response.sendRedirect("http://localhost:3000/");
}
private Cookie createCookie(String key, String value) {
Cookie cookie = new Cookie(key, value);
cookie.setMaxAge(60 * 60 * 60); //쿠키가 살아있을 시간
// cookie.setSecure(true); https에서만 동작하도록 하는 설정(local 환경은 http이므로 주석처리)
cookie.setPath("/"); //전역에서 쿠키 확인 가능
cookie.setHttpOnly(true); //자바 스크립트가 쿠키를 가져가지 못하게 하는 설정
return cookie;
}
}
@Configuration
@EnableWebSecurity
public class SecurityConfig {
private final CustomClientRegistrationRepo customClientRegistrationRepo;
private final CustomOAuth2UserService customOAuth2UserService;
private final CustomSuccessHandler customSuccessHandler;
private final JwtUtil jwtUtil;
public SecurityConfig(CustomClientRegistrationRepo customClientRegistrationRepo, CustomOAuth2UserService customOAuth2UserService, CustomSuccessHandler customSuccessHandler, JwtUtil jwtUtil) {
this.customClientRegistrationRepo = customClientRegistrationRepo;
this.customOAuth2UserService = customOAuth2UserService;
this.customSuccessHandler = customSuccessHandler;
this.jwtUtil = jwtUtil;
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
//csrf disable
http
.csrf((csrf) -> csrf.disable());
//formLogin disable
http
.formLogin((auth) -> auth.disable());
//httpBasic disable
http
.httpBasic((auth) -> auth.disable());
//oauth2 설정
http
.oauth2Login((oauth2) -> oauth2
.clientRegistrationRepository(customClientRegistrationRepo.clientRegistrationRepository())
.userInfoEndpoint((userInfoEndpointConfig) -> userInfoEndpointConfig
.userService(customOAuth2UserService))
.successHandler(customSuccessHandler));
//경로별 인가 작업
http
.authorizeHttpRequests((auth) -> auth
.requestMatchers("/").permitAll()
.anyRequest().authenticated());
//세션 설정 STATELESS
http
.sessionManagement((session) -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
return http.build();
}
public class JwtFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
public JwtFilter(JwtUtil jwtUtil) {
this.jwtUtil = jwtUtil;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
//cookie들을 불러온 뒤 Authorization key에 담긴 쿠키를 찾음
String authorization = null;
Cookie[] cookies = request.getCookies();
for (Cookie cookie : cookies) {
if(cookie.getName().equals("Authorization")) {
authorization = cookie.getValue();
}
}
//Authorization 검증
if(authorization == null) {
filterChain.doFilter(request, response);
//조건이 해당되면 메소드 종료(필수)
return;
}
//토큰
String token = authorization;
//토큰 소멸 시간 검증
if (jwtUtil.isExpired(token)) {
System.out.println("token is expired");
filterChain.doFilter(request, response);
//조건이 해당되면 메소드 종료(필수)
return;
}
//토큰에서 username과 role 획득
String username = jwtUtil.getUsername(token);
String role = jwtUtil.getRole(token);
//userDTO를 생성하여 값 set
UserDto userDto = new UserDto();
userDto.setUsername(username);
userDto.setRole(role);
//UserDetails에 회원 정보 객체 담기
CustomOAuth2user customOAuth2user = new CustomOAuth2user(userDto);
//스프링 시큐리티 인증 토큰 생성
Authentication authToken = new UsernamePasswordAuthenticationToken(customOAuth2user, null, customOAuth2user.getAuthorities());
//세션에 사용자 등록
SecurityContextHolder.getContext().setAuthentication(authToken);
filterChain.doFilter(request, response);
}
}
@Configuration
@EnableWebSecurity
public class SecurityConfig {
private final CustomClientRegistrationRepo customClientRegistrationRepo;
private final CustomOAuth2UserService customOAuth2UserService;
private final CustomSuccessHandler customSuccessHandler;
private final JwtUtil jwtUtil;
public SecurityConfig(CustomClientRegistrationRepo customClientRegistrationRepo, CustomOAuth2UserService customOAuth2UserService, CustomSuccessHandler customSuccessHandler, JwtUtil jwtUtil) {
this.customClientRegistrationRepo = customClientRegistrationRepo;
this.customOAuth2UserService = customOAuth2UserService;
this.customSuccessHandler = customSuccessHandler;
this.jwtUtil = jwtUtil;
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
//csrf disable
http
.csrf((csrf) -> csrf.disable());
//formLogin disable
http
.formLogin((auth) -> auth.disable());
//httpBasic disable
http
.httpBasic((auth) -> auth.disable());
//필터 등록
http
.addFilterBefore(new JwtFilter(jwtUtil), UsernamePasswordAuthenticationFilter.class);
//oauth2 설정
http
.oauth2Login((oauth2) -> oauth2
.clientRegistrationRepository(customClientRegistrationRepo.clientRegistrationRepository())
.userInfoEndpoint((userInfoEndpointConfig) -> userInfoEndpointConfig
.userService(customOAuth2UserService))
.successHandler(customSuccessHandler));
//경로별 인가 작업
http
.authorizeHttpRequests((auth) -> auth
.requestMatchers("/").permitAll()
.anyRequest().authenticated());
//세션 설정 STATELESS
http
.sessionManagement((session) -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
return http.build();
}
}
@Configuration
@EnableWebSecurity
public class SecurityConfig {
private final CustomClientRegistrationRepo customClientRegistrationRepo;
private final CustomOAuth2UserService customOAuth2UserService;
private final CustomSuccessHandler customSuccessHandler;
private final JwtUtil jwtUtil;
public SecurityConfig(CustomClientRegistrationRepo customClientRegistrationRepo, CustomOAuth2UserService customOAuth2UserService, CustomSuccessHandler customSuccessHandler, JwtUtil jwtUtil) {
this.customClientRegistrationRepo = customClientRegistrationRepo;
this.customOAuth2UserService = customOAuth2UserService;
this.customSuccessHandler = customSuccessHandler;
this.jwtUtil = jwtUtil;
}
@Bean
public SecurityFilterChain securityFilterChain(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")); //프론트 주소
configuration.setAllowedMethods(Collections.singletonList("*")); //get, put, post 등 모든 요청 허용
configuration.setAllowCredentials(true); //credential 값 가지고 올 수 있도록 허용
configuration.setAllowedHeaders(Collections.singletonList("*")); //어떤 헤더를 받을 수 있는지 설정
configuration.setMaxAge(3600L);
//우리가 데이터를 줄 경우 웹페이지에서 보이게 할 수 있는 방법
configuration.setExposedHeaders(Collections.singletonList("Set-Cookie"));
configuration.setExposedHeaders(Collections.singletonList("Authorization"));
return configuration;
}
}));
//csrf disable
http
.csrf((csrf) -> csrf.disable());
//formLogin disable
http
.formLogin((auth) -> auth.disable());
//httpBasic disable
http
.httpBasic((auth) -> auth.disable());
//필터 등록
http
.addFilterBefore(new JwtFilter(jwtUtil), UsernamePasswordAuthenticationFilter.class);
//oauth2 설정
http
.oauth2Login((oauth2) -> oauth2
.clientRegistrationRepository(customClientRegistrationRepo.clientRegistrationRepository())
.userInfoEndpoint((userInfoEndpointConfig) -> userInfoEndpointConfig
.userService(customOAuth2UserService))
.successHandler(customSuccessHandler));
//경로별 인가 작업
http
.authorizeHttpRequests((auth) -> auth
.requestMatchers("/").permitAll()
.anyRequest().authenticated());
//세션 설정 STATELESS
http
.sessionManagement((session) -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
return http.build();
}
}
@Configuration
public class CorsMvcConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.exposedHeaders("Set-Cookie")
.allowedOrigins("http://localhost:3000");
}
}
//JWTFilter 추가
http
.addFilterAfter(new JWTFilter(jwtUtil), OAuth2LoginAuthenticationFilter.class);
String requestUri = request.getRequestURI();
if (requestUri.matches("^\\/login(?:\\/.*)?$")) {
filterChain.doFilter(request, response);
return;
}
if (requestUri.matches("^\\/oauth2(?:\\/.*)?$")) {
filterChain.doFilter(request, response);
return;
}