-
동작 흐름
- OAuth2 login
//OAuth login OAuth2LoginAuthenticationFilter -> attemptAuthentication(request,response) 메서드 동작 -> AuthenticationManager에 authenticate메서드 호출 -> OAuth2LoginAuthenticationProvider의 authenticate메서드가 다시 호출됨 -> 로그인 시도를 통해 발급받은 외부인증서버에 대한 access 토큰 정보, 혹은 유저 정보를 OAuth2UserRequest에 담아서 UserService.loadUser()메서드 호출 -> 여기서 UserService는 Custom으로 만든 UserService를 의미함! -> 원래 기능은 외부에서 주어진 값 (OAuth2UserRequest)으로 UserService내부에서 해당 유저가 가입된 유저인지 확인하는 작업이 필요 하지만, SNS+JWT이므로 내부에서 토큰을 만들거나, 첫 sns로그인 시 필요한 정보들만 담아서 UserDetails로 반환 -> 다시 Provider -> Manager -> LoginFilter로 돌아와서 해당 정보를 임시 세션에 저장 -> 로그인이 완료 되었으니, 다음 처리를 위해 success handler 작동시킴(토큰 발급 혹은 회원가입 페이지로 이동시키기) -> 이 흐름에서 우리가 작성해야할 것은 UserService, successHandler뿐!
- JWT 인증 및 인가
//OAuth로 회원 등록 혹은 로그인이 완료되었다면, 다음 요청부터는 토큰이 필요함 // 모든 요청에 대해 토큰을 발급할 클래스와 모든 요청에 대해 토큰을 검증할 필터 필요 //위 흐름은 로그인 흐름이 아니라, 로그인 완료 후 세션 없이 로그인 유지 흐름임 //원래 form로그인 이라면, UserNameAndPasswordFilter가 처음에 Form으로 유저 정보를 받음 (OAuth2의 OAuth2LoginAuthenticationFiler와 비슷한 위치라고 보면 됨) 근데 시큐리티에서 폼 로그인을 끄면, 위 필터가 등록에서 제외됨 따라서 JWT를 검증할 다른 필터를 만들어서 사용 OAuth2 마지막 로그인에서 success Handler가 작동하면서 sns로 회원가입한 유저면, token을 발급하고, 아니면, 회원가입 페이지로 이동시키면 됨 이때 Redirect시에 sns로그인을 시도한 email과 provider 정보등을 함께 넘김! (필요에 따라 더 넘길 수 있음) -> 토큰이 발급 된 후 -> 유저가 요청을 보냄(토큰을 http request 헤더에 담음) -> jwtTokenAuthFilter 작동 -> 토큰을 검증 -> 만료된 토큰 -> refresh토큰이 만료 이전 -> access토큰 재발급 시키고, 요청 통과 -> refresh토큰 만료시 -> 다시 재로그인 시킴 -> 조작된 토큰 -> 에러페이지 혹은 메인으로 보내버리기 -> 토큰 검증이 완료되었으면, 임시세션에 로그인한 유저 정보를 담아서 요청을 통과시킴 -> 이후 컨트롤러 단에서 로직을 처리하면서, 임시세션에 담긴 유저정보를 꺼내서 처리
@EnableWebSecurity @Configuration @RequiredArgsConstructor public class HorokSecurityConfig { @EnableWebSecurity @Configuration @RequiredArgsConstructor public class HorokSecurityConfig { //테스트 용도 private final TestHandler testHandler; //Service private final HorokOAuthUserService horokOAuthUserService; // private final TokenService tokenService; private final JwtExceptionFilter jwtExceptionFilter; private final JwtTokenAuthFilter jwtTokenAuthFilter; @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception{ //csrf 일단 disable (api서버는 꺼도 된다함) http.csrf((auth) -> auth.disable()) //form로그인은 안하기때문에 끔 .formLogin((auth) -> auth.disable()) .httpBasic((auth)->auth.disable()) //세션은 따로 생성x 시큐리티 임시세션을 이용할 것 따라서 stateless로 바꾸기 .sessionManagement((s)->s.sessionCreationPolicy(SessionCreationPolicy.STATELESS)); http.authorizeHttpRequests((auth) -> //몇가지 경로는 시큐리티 필터 작동없이 통과하도록 바꿈 auth.requestMatchers("/","/oauth2/**","/login/**").permitAll() .anyRequest().authenticated() ). //jwt인증필터는 usernamePasswordAuthenticationFiler바로 뒤에 작동하도록 둠 addFilterBefore(jwtTokenAuthFilter, UsernamePasswordAuthenticationFilter.class) //토큰 검증 중에 발생하는 Exception을 다루기 위해 등록한 필터 .addFilterBefore(jwtExceptionFilter, JwtTokenAuthFilter.class). // oauth2 login관련 oauth2Login((oauth2)-> //성공시 동작할 successHandler 등록 oauth2.successHandler( testHandler) //custom UserService 등록 .userInfoEndpoint((us)->us.userService(horokOAuthUserService)) .failureHandler(horokAuthenticationFailureHandler)); return http.build(); } }
**프론트랑 같이 동작하기 위해 추후에 cors 작업을 추가해야함 !! 인도형 참고해서 만들자
1. OAuth2 로그인 관련
1.1 OAuth2 UserService
@Service @RequiredArgsConstructor @Slf4j public class HorokOAuthUserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> { private final UserLoginInfoRepository userLoginInfoRepository; @Override public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { //기본 OAuth2USerService 생성 OAuth2UserService<OAuth2UserRequest,OAuth2User> oAuth2UserService = new DefaultOAuth2UserService(); //OAuth2UserSerivce를 사용하여 OAuth2User 정보 가져옴 //userRequest가 외부 인증서버에서 발급받은 토큰개념임 OAuth2User oAuth2User = oAuth2UserService.loadUser(userRequest); // oauth2에서 받은 access 토큰에서 유저정보 꺼낸 것 //Naver,kakao같은 정보 String registrationId = userRequest.getClientRegistration().getRegistrationId(); //사용자명 String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName(); //기본 userService에서 가져온 유저 정보를 기반으로 OAuth2Attribute 객체를 만든다. Oauth2Attribute oauth2Attribute = Oauth2Attribute.of(registrationId,userNameAttributeName,oAuth2User.getAttributes()); //파싱한 값들 중 필요 정보를 담은 Map임 Map<String, Object> memberAttribute = oauth2Attribute.convertToMap(); String email = (String) memberAttribute.get("email"); //등록된 이메일을 기준으로 우리 어플리케이션에 저장된 회원인지 아닌지 판단 Optional<UserLoginInfo> findloginInfo = userLoginInfoRepository.findByUserLoginEmail(email); if (findloginInfo.isEmpty()) { // 회원이 존재하지 않을경우, memberAttribute의 exist 값을 false로 넣어준다. memberAttribute.put("exist", false); // 회원의 권한(회원이 존재하지 않으므로 기본권한인 ROLE_USER를 넣어준다), 회원속성, 속성이름을 이용해 DefaultOAuth2User 객체를 생성해 반환한다. return new DefaultOAuth2User( Collections.singleton(new SimpleGrantedAuthority("ROLE_USER")), memberAttribute, "email"); } // 회원이 존재할경우, memberAttribute의 exist 값을 true로 넣어준다. memberAttribute.put("exist", true); // 회원의 권한과, 회원속성, 속성이름을 이용해 DefaultOAuth2User 객체를 생성해 반환한다. return new DefaultOAuth2User( Collections.singleton(new SimpleGrantedAuthority("ROLE_".concat(findloginInfo.get().getUserLoginRole()))), memberAttribute, "email"); } }
//기본 userService에서 가져온 유저 정보를 기반으로 OAuth2Attribute 객체를 만든다. Oauth2Attribute oauth2Attribute = Oauth2Attribute.of(registrationId,userNameAttributeName,oAuth2User.getAttributes());
-> 이를 만드는 이유는 sns마다 넘어오는 정보를 파싱하기 위한 로직이 다르기 때문에 이를 처리할 클래스를 임의로 만든 것
String email = (String) memberAttribute.get("email"); //등록된 이메일을 기준으로 우리 어플리케이션에 저장된 회원인지 아닌지 판단 Optional<UserLoginInfo> findloginInfo = userLoginInfoRepository.findByUserLoginEmail(email);
-> 이메일 혹은 나름대로의 값을 통해 Jwt토큰을 발급, 검증 혹은 등록된 유저인지 아닌지 판단할 것이다.
1.2 Oauth2Attribute (DAO)
@ToString @Builder(access = AccessLevel.PRIVATE) @Getter public class Oauth2Attribute { private Map<String,Object> attributes; // 사용자 속성 정보를 담는 Map private String attributeKey; // 사용자 속성의 키 값 private String email; private String name; private String provider; public static Oauth2Attribute of(String provider,String attributeKey, Map<String,Object> attributes){ switch (provider){ case "google": return ofGoogle(provider, attributeKey, attributes); case "kakao": return ofKakao(provider,"id", attributes); case "naver": return ofNaver(provider, "email", attributes); default: throw new RuntimeException(); } } private static Oauth2Attribute ofGoogle(String provider, String attributeKey, Map<String, Object> attributes) { return Oauth2Attribute.builder() .email((String) attributes.get("email")) .provider(provider) .attributes(attributes) .attributeKey(attributeKey) .build(); } private static Oauth2Attribute ofKakao(String provider, String attributeKey, Map<String, Object> attributes) { Map<String, Object> kakaoAccount = (Map<String, Object>) attributes.get("kakao_account"); Map<String, Object> kakaoProfile = (Map<String, Object>) kakaoAccount.get("profile"); return Oauth2Attribute.builder() .email((String) kakaoAccount.get("email")) .provider(provider) .attributes(kakaoAccount) .attributeKey(attributeKey) .build(); } private static Oauth2Attribute ofNaver(String provider, String attributeKey, Map<String, Object> attributes) { Map<String, Object> response = (Map<String, Object>) attributes.get("response"); return Oauth2Attribute.builder() .email((String) response.get("email")) .name((String) response.get("name")) .attributes(response) .provider(provider) .attributeKey(attributeKey) .build(); } // OAuth2User 객체에 넣어주기 위해서 Map으로 값들을 반환해준다. public Map<String, Object> convertToMap() { Map<String, Object> map = new HashMap<>(); map.put("id", attributeKey); map.put("key", attributeKey); map.put("email", email); map.put("provider", provider); map.put("name",name); return map; } }
-> 나머지는 sns에 따라 파싱하는 것 뿐이고, 파싱된 값을 Map의 형태로 꺼내기 위한 메서드는 아래와 같다.
// OAuth2User 객체에 넣어주기 위해서 Map으로 값들을 반환해준다. public Map<String, Object> convertToMap() { Map<String, Object> map = new HashMap<>(); map.put("id", attributeKey); map.put("key", attributeKey); map.put("email", email); map.put("provider", provider); map.put("name",name); return map; }
-> 해당 값은 jwt토큰을 발급하거나, sns로 회원가입이 안된 유저를 회원가입하도록 유도하는데 사용된다.
-> 따라서 필요에 따라 커스텀하자
1.3 successHandler
@Component @Slf4j @RequiredArgsConstructor public class TestHandler extends SimpleUrlAuthenticationSuccessHandler { private final JwtUtil jwtUtil; private final UsersRepository usersRepository; private final UserLoginInfoRepository userLoginInfoRepository; private static final String REDIRECT_URL = "http://localhost:8081/loginSuccess"; @Override @Transactional public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { //자신한테 보내면 //getRedirectStrategy().sendRedirect(request,response,REDIRECT_URL);\ //내 컴퓨터로는 절대 못보내고, 다른 컴퓨터로는 보낼 수 있음 //response.sendRedirect("/loginSuccess"); //따라서 redirect 보내지말고, 그냥 결과 화면만 보고 저장된 걸로 접근해보아야 겠다. //공통로직 // OAuth2User로 캐스팅하여 인증된 사용자 정보를 가져온다. (로그인시 Oauth2Service를 통해 검증받은 부분) OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal(); log.info("attribute {}",oAuth2User.getAttributes()); // 사용자 이메일을 가져온다. String email = oAuth2User.getAttribute("email"); // 서비스 제공 플랫폼(GOOGLE, KAKAO, NAVER)이 어디인지 가져온다. String provider = oAuth2User.getAttribute("provider"); // CustomOAuth2UserService에서 셋팅한 로그인한 회원 존재 여부를 가져온다. boolean isExist = oAuth2User.getAttribute("exist"); // OAuth2User로 부터 Role을 얻어온다. String role = oAuth2User.getAuthorities().stream(). findFirst() // 첫번째 Role을 찾아온다. .orElseThrow(IllegalAccessError::new) // 존재하지 않을 시 예외를 던진다. .getAuthority(); // Role을 가져온다. //회원가입 완료 유저인지, 새로 회원가입 시켜야하는 유저인지 판단 if(isExist){ // 회원이 존재하면 jwt token 발행을 시작한다. GeneratedToken token = jwtUtil.generatedToken(email,role); log.info("jwtToken = {}", token.getAccessToken()); //원래는 헤더에 담아서 보내줘야하는데, 그냥 보여주자 response.setContentType("text/html;charset=utf-8"); response.setCharacterEncoding("utf-8"); PrintWriter writer = response.getWriter(); writer.write(token.toString()); }else{ //유저정보 생성해서 저장 UserLoginInfo userLoginInfo = UserLoginInfo .builder().userLoginEmail(email) .userLoginRole(role) .build(); Users users = Users.builder().userNickname("강태바리").userLoginInfo(userLoginInfo) .build(); userLoginInfo.setUser(users); usersRepository.save(users); response.setContentType("text/html;charset=utf-8"); response.setCharacterEncoding("utf-8"); PrintWriter writer = response.getWriter(); writer.write("user created"); } } }
- UserService에서 return한 OAuthUser를 임시 세션에서 꺼낸다.
- 토큰을 발급하거나, 회원가입 시키기 위해 필요한 정보를 꺼내서 넣는다.
- 여기는 완전히 커스텀 영역이다!
*주의할점 테스트를 위해 로컬 백엔드 서버로 리다이렉트 시키면 무한 루프를 돈다.
-> 이유는 백엔드 서버는 oauth2에서 미리 설정한 redirect-uri로 가야하는데 중간에 redirect시킨것과 충돌하는 듯 하다
1.4 jwt util
@Service @RequiredArgsConstructor @Slf4j public class JwtUtil { private SecretKey secretKey; @Value("${spring.jwt.secret}") private String sign; @PostConstruct protected void init(){ // secretkey = Base64.getEncoder().encodeToString(sign.getBytes()); secretKey = new SecretKeySpec(sign.getBytes(StandardCharsets.UTF_8), Jwts.SIG.HS256.key().build().getAlgorithm()); } public GeneratedToken generatedToken(String email, String role){ // refreshToken And accessToken 쌍으로 생성 String refreshToken = generteRefreshToken(email,role); String accessToken = generateAccessToken(email, role); //token을 Mysql에 저장 리프레쉬 토큰 때문임 return new GeneratedToken(accessToken,refreshToken); } public String generteRefreshToken(String email, String role){ //토큰의 유효 기간을 설정 long refreshExpiration = 1000L * 60L * 60L * 24L * 7; // 1주 return Jwts.builder() .claim("email",email) .claim("role",role) .issuedAt(new Date(System.currentTimeMillis())) .expiration(new Date(System.currentTimeMillis()+refreshExpiration)) .signWith(secretKey) .compact(); } public String generateAccessToken(String email,String role){ long tokenPeriod = 1000L * 60L * 30L; //30분 return Jwts.builder() .claim("email",email) .claim("role",role) .issuedAt(new Date(System.currentTimeMillis())) .expiration(new Date(System.currentTimeMillis()+tokenPeriod)) .signWith(secretKey) .compact(); } public boolean isExpired(String token) throws Exception{ // 검증시 발생할 수 있는 예외는 필터에서 잡을 것 따라서 여기선 // 단순히 토큰이 유효한지만 검사 // 예외 함 봐보자 boolean before = Jwts.parser().verifyWith(secretKey) .build() .parseSignedClaims(token) .getPayload() .getExpiration() .before(new Date()); return before; } public String getEmail(String token){ return Jwts.parser() .verifyWith(secretKey) .build() .parseSignedClaims(token) .getPayload() .get("email",String.class); } }
1.4.1 발급한 토큰을 담아둘 DTO
@Data public class GeneratedToken { private String accessToken; private String refreshToken; public GeneratedToken(String accessToken, String refreshToken) { this.accessToken = accessToken; this.refreshToken = refreshToken; } }
- 토큰을 발급하기 이전에 먼저
시크릿 값을 넣기 위해 아래와 같이 properties파일이나, 혹은 다른 어딘가에 값을 저장해두자
spring.jwt.secret=주먹으로 내려친 무슨무슨 코드
jwt라이브러리는 아래 버전을 사용했다.
//jwt 관련 라이브러리 implementation 'io.jsonwebtoken:jjwt-api:0.12.3' implementation 'io.jsonwebtoken:jjwt-impl:0.12.3' implementation 'io.jsonwebtoken:jjwt-jackson:0.12.3'
-> jwt를 발급하면서 payoad에 넣을 값과 secret key는 커스텀이다.
-> jwt에 payload는 나중에 유저 검증을 위해 사용될 것이다.
2. JWT 로그인 인증 관련
2.1 로그인 인증필터
@RequiredArgsConstructor @Slf4j @Component public class JwtTokenAuthFilter extends OncePerRequestFilter { private final JwtUtil jwtUtil; private final UsersRepository usersRepository; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { //request Header에서 Access Token을 가져온다. String acctoken = request.getHeader("Authorization"); log.info(acctoken); //모두 허용 URL일 경우 토큰 검사 생략 if(!StringUtils.hasText(acctoken)){ doFilter(request,response,filterChain); return; } //AccessToken을 검증, 만료되었을 경우 예외를 발생시킨다.(리프레쉬 토큰 확인해서 재발급) try { jwtUtil.isExpired(acctoken); }catch (ExpiredJwtException es){ //토큰이 expired 된 것 처리 throw new AccessTokenExpiredException("Access Token 만료"); } catch (Exception e) { //토큰 조작 처리 //나머지 Eecption log.info(e.getMessage()); throw new RuntimeException(e); } log.info("여기까지 작동"); //accessToken의 값이 유효함 //email 혹은 특정 값으로 멤버 조회 //Users byUserNickname = usersRepository.findByUserNickname("예민경영") AuthUserDto authUserDto= AuthUserDto.builder() .userId(1).email("mimi5963").nickname("윤꿀꿀").build(); //SecurityContext에 인증 객체를 등록 (1회용) -> 세션이 Stateless라그럼 Authentication auth = new UsernamePasswordAuthenticationToken(authUserDto,"", List.of(new SimpleGrantedAuthority("ROLE_USER"))); SecurityContextHolder.getContext().setAuthentication(auth); log.info("여기까지 작동 맘우리"); //authUserDto가 principal로 셋팅된다. //authUserDto를 얻기 위해서는 // SecurityContextHolder.getContext().~~->..getPrincipal()이다 // 걍 어노테이션으로 처리하자 filterChain.doFilter(request, response); } }
- jwt에 값을 파싱해서 DB에 유저를 조회하자 이후에 임시 세션에 담을 유저정보를 셋팅한다.
//email 혹은 특정 값으로 멤버 조회 //Users byUserNickname = usersRepository.findByUserNickname("예민경영") AuthUserDto authUserDto= AuthUserDto.builder() .userId(1).email("mimi5963").nickname("윤꿀꿀").build();
- 커스텀 영역이다.
- DB에서 조회한 유저정보를 DTO로 바꾼 값을 사용해서 Authentication객체로 만들어서 시큐리티 임시세션에 저장한다.
Authentication auth = new UsernamePasswordAuthenticationToken(authUserDto,"", List.of(new SimpleGrantedAuthority("ROLE_USER"))); SecurityContextHolder.getContext().setAuthentication(auth);
* 세션을 stateless로 설정해두었기 때문에, 하나의 요청에 임시세션이 생성된다.
따라서 모든 요청을 검증함과 동시에 해당 임시세션에 유저정보를 채워줘야한다.
2.2 토큰 Exception 처리 필터
@Component @RequiredArgsConstructor public class JwtExceptionFilter extends OncePerRequestFilter { private final ObjectMapper objectMapper; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { try { filterChain.doFilter(request,response); }catch (AccessTokenExpiredException e){ response.setStatus(401); response.setContentType(MediaType.APPLICATION_JSON_VALUE); response.setCharacterEncoding("UTF-8"); objectMapper.writeValue(response.getWriter(), StatusResponseDto.builder().status(401).build()); }catch (Exception e){ StatusResponseDto build = StatusResponseDto.builder().status(401).msg("Access 토큰이 조작 혹은 해당 애플리케이션에서 발행한 토큰이 아닙니다.").build(); response.setStatus(401); response.setContentType(MediaType.APPLICATION_JSON_VALUE); response.setCharacterEncoding("UTF-8"); objectMapper.writeValue(response.getWriter(),build); } } }
- 토큰 검증 과정에서 발생한 에러를 처리하기 위한 필터이다.
-> 추후에 access토큰이 만료되었을 때 다시 발급받기 위한 로직을 위해 적어둔 Exception이다ㅓ.
public class AccessTokenExpiredException extends RuntimeException{ public AccessTokenExpiredException(String msg){ super(msg); } }
- Exception 상황에서 클라이언트에 응답을 담당할 객체이다.
- Object Mapper클래스를 통해 아래 Dto에 담긴 결과를 json형식으로 뿌려주기 위해 작성했다.
@Getter @Builder @JsonInclude(JsonInclude.Include.NON_NULL) public class StatusResponseDto { private Integer status; private Object data; private String msg; public static StatusResponseDto success(){ return StatusResponseDto.builder().status(200).build(); } }