들어가기 전에
구글링을 통해 소셜 로그인 구현을 찾아보면, 하기 라이브러리들을 통한 구현이 많습니다.
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
이 경우, 주로 back-end(JAVA)에서 소셜 로그인을 A to Z까지 진행하는 경우가 많습니다. 만약, 필요한 것이 back-end에서 로그인을 전부 하는 것이라면 하기 글이 도움되지 않을 수 있습니다. 해당 부분 고려하시어 보시면 좋을 것 같습니다😄
이번 프로젝트의 경우, front-end에서 로그인을 진행하고, 로그인 이후 얻은 accessToken(또는 idToken)을 이용해 사용자 정보를 가져오고, 우리의 프로젝트의 JWT 토큰을 생성해 front-end에 전달하여 이후 통신에서 어플리케이션의 JWT 토큰을 이용해 사용자가 누구인지, 유효한 사용자인지 체크하고자 했습니다.
하기 글에서는 소셜 로그인 시 필요한 AccessToken(카카오)와 App의 AccessToken의 이름이 동일하여 혼란스러울 수 있어 AppToken이라고 적은 경우도 있습니다. AppToken이 App의 AccessToken이라고 봐주시면 됩니다.
구현하고자 하는 소셜 로그인 구조
아래 그림은 이 프로젝트의 로그인 및 사용자 정보를 가져오는 구조를 간단하게 나타낸 것입니다.
그림에서 나타낸것과 같이 backend(JAVA)에서는 소셜 로그인을 통해 얻은 Access Token(또는 Id Token)을 통해 사용자 정보를 가져오고, 사용자를 식별할 수 있는 sociaId값을 이용해 앱의 JWT 토큰을 만들어 frontend에 반환해줍니다.
이후 통신에서는 앱의 JWT 토큰만을 가지고 사용자를 식별하게 됩니다.
코드로 살펴보기
AuthController.java
@RestController
@RequestMapping("/auth")
@RequiredArgsConstructor
public class AuthController {
private final KakaoAuthService kakaoAuthService;
private final GoogleAuthService googleAuthService;
private final AuthTokenProvider authTokenProvider;
private final AuthService authService;
/**
* KAKAO 소셜 로그인 기능
* @return ResponseEntity<AuthResponse>
*/
@ApiOperation(value = "카카오 로그인", notes = "카카오 엑세스 토큰을 이용하여 사용자 정보 받아 저장하고 앱의 토큰 신환")
@PostMapping(value = "/kakao")
public ResponseEntity<AuthResponse> kakaoAuthRequest(@RequestBody AuthRequest authRequest) {
return ApiResponse.success(kakaoAuthService.login(authRequest)); // body에 appToken 반환(response code 200)
}
/**
* GOOGLE 소셜 로그인 기능
* @return ResponseEntity<AuthResponse>
*/
@ApiOperation(value = "구글 로그인", notes = "구글 엑세스 토큰을 이용하여 사용자 정보 받아 저장하고 앱의 토큰 신환")
@PostMapping(value = "/google")
public ResponseEntity<AuthResponse> googleAuthRequest(@RequestBody AuthRequest authRequest) {
return ApiResponse.success(googleAuthService.login(authRequest)); // body에 appToken 반환(response code 200)
}
/**
* appToken 갱신
* @return ResponseEntity<AuthResponse>
*/
@ApiOperation(value = "appToken 갱신", notes = "appToken 갱신")
@GetMapping("/refresh")
public ResponseEntity<AuthResponse> refreshToken (HttpServletRequest request) {
String appToken = JwtHeaderUtil.getAccessToken(request);
AuthToken authToken = authTokenProvider.convertAuthToken(appToken);
if (!authToken.validate()) { // 형식에 맞지 않는 token
return ApiResponse.forbidden(null); // body에 담은 것 없이, 403 HTTP code return
}
AuthResponse authResponse = authService.updateToken(authToken);
if (authResponse == null) { // token 만료
return ApiResponse.forbidden(null); // body에 담은 것 없이, 403 HTTP code return
}
return ApiResponse.success(authResponse);
}
}
SecurityConfig.java
@RequiredArgsConstructor
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
// Spring Security Configuration Class를 작성하기 위해서는 WebSecurityConfigurerAdapter를 상속하여 클래스를 생성하고 @EnableWebSecurity 어노테이션을 추가해야 합니다(@Configuration 어노테이션 대신 사용).
private final AuthTokenProvider authTokenProvider;
@Override
public void configure(WebSecurity web) throws Exception {
// swagger-ui.html의 경우 인증된 사용자가 아니어도 접근가능하도록 설정(dev환경에 대해서만 swagger 설정을 하였기 때문에 인증된 사용자가 아니어도 됨)
web.ignoring().antMatchers("/v2/api-docs", "/configuration/**", "/swagger*/**", "/webjars/**");
}
@Override
protected void configure(HttpSecurity http) throws Exception {
JwtAuthenticationFilter jwtAuthFilter = new JwtAuthenticationFilter(authTokenProvider);
http
.authorizeRequests()
.antMatchers(HttpMethod.OPTIONS).permitAll() // preflight 대응
.antMatchers("/auth/**").permitAll() // /auth/**에 대한 접근을 인증 절차 없이 허용(로그인 관련 url)
// 특정 권한을 가진 사용자만 접근을 허용해야 할 경우, 하기 항목을 통해 가능
//.antMatchers("/admin/**").hasAnyRole("ADMIN")
.anyRequest().authenticated() // 위에서 따로 지정한 접근허용 리소스 설정 후 그 외 나머지 리소스들은 무조건 인증을 완료해야 접근 가능
.and()
.headers() // 아래에 X-Frame-Option 헤더 설정을 위해 headers() 작성
.frameOptions().sameOrigin() // 동일 도메인에서는 iframe 접근 가능하도록 X-Frame-Options을 smaeOrigin()으로 설정
.and()
.cors()
.and()
.csrf().disable()
// 예외 처리를 하고 싶다면 아래와 같이 작성 가능합니다.
//.exceptionHandling() // 예외 처리 지정
//.authenticationEntryPoint(new CustomAuthenticationEntryPoint())
//.accessDeniedHandler(new CustomAccessDeniedHandler())
.and()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class); // 커스텀 필터 등록하며, 기존에 지정된 필터에 앞서 실행
}
}
WebSecurity
를 파라미터로 받는configure
의 경우,swagger
관련 페이지에 대해jwtAuthFilter
를 타지 않게 하기 위해ignore 처리
를 한 것입니다.HttpSecurity
는WebSecurity
앞단에서 configuration이 진행되어HttpSecurty
에 설정한 security 설정이 WebSecurity에 의해 오버라이딩이 가능합니다.- 현재 swagger 설정은
application-dev.yml
에 대해서만 열어두었기 때문에 운영 환경에서 접속이 불가하므로 authentication 없이도 접근하게 하여 개발 시 front-end분들이 작업하시는 데 편하도록 구성했습니다.
JWT 토큰
을 사용하기 때문에 session을 사용하지 않는STATELESS
로 구성하였습니다.- 로그인 시
application/json
형식으로 데이터를 보내주어 CORS preflight가 발생하여 OPTIONS method에 대해 허용을 해주었습니다. - iFrame을 사용할 수도 있을 것이라 생각하여, 동일 도메인에 대해서는 iFrame 접근이 가능하도록 설정하였습니다.
[ Spring Security 설정 ]
Spring Security Configuration Class
를 작성하기 위해서는 WebSecurityConfigurerAdapter
를 상속하여 클래스를 생성하고 @EnableWebSecurity
어노테이션을 추가해야 합니다(@Configuration
어노테이션 대신 사용).
- WebSecurity 설정(
configure(WebSecurity web) {}
)- HttpSecurity 설정이 먼저 이루어진 후 WebSecurity 설정이 진행됩니다. 따라서, 오버라이딩이 필요한 항목들은 WebSecurity에 작성하면 됩니다.
- HttpSecurity 설정(
configure(HttpSecurity http) {}
)- 리소스(URL) 접근 권한 설정
- 인증 전체 흐름에 필요한 login, logout, userInfo 등에서 인증 실패시 이동하는 곳 지정
- csrf, cors 관련 설정
- 커스텀 필터(예: 커스텀 필터를 통한 exception handling)
JwtAuthenticationFilter.java
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final AuthTokenProvider tokenProvider;
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
final String authorizationHeader = request.getHeader("Authorization"); // Authorization 헤더 꺼냄
if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) { // JWT 토큰이 존재하는지 확인
String tokenStr = JwtHeaderUtil.getAccessToken(request); // Bearer로 시작하는 값에서 Bearer를 제거한 accessToken(appToken) 반환
AuthToken token = tokenProvider.convertAuthToken(tokenStr); // String to AuthToken
if (token.validate()) { // token이 유효한지 확인
Authentication authentication = tokenProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication); // token에 존재하는 authentication 정보 삽입
}
filterChain.doFilter(request, response);
}
}
}
- 접근 시, appToken(JWT token)에 해당하는 토큰이 요청의 header 내에 Bearer 토큰으로 들어있는지 체크하는 부분입니다.
- 사실상,
/auth/kakao
와 같이 첫 로그인 시도에는 appToken을 가지고 있지 않아 에러가 발생할 수 있어 따로"Invalid JWT token"
이라고 로그에 남기면서 catch하고 있습니다.
- 사실상,
AuthTokenProvider.java
@Component
public class AuthTokenProvider {
@Value("${app.auth.tokenExpiry}")
private String expiry; // 토큰 만료일
private final Key key;
private static final String AUTHORITIES_KEY = "role"; // getAuthentication에서 사용자 권한 체크 위함
public AuthTokenProvider(@Value("${app.auth.tokenSecret}") String secretKey) { // 생성자
this.key = Keys.hmacShaKeyFor(secretKey.getBytes());
}
public AuthToken createToken(String id, RoleType roleType, String expiry) { // 추후 roleType 추가 시 interface 역할 하기 위해 생성
Date expiryDate = getExpiryDate(expiry);
return new AuthToken(id, roleType, expiryDate, key);
}
public AuthToken createUserAppToken(String id) { // USER에 대한 AccessToken(AppToken) 생성
return createToken(id, RoleType.USER, expiry);
}
public AuthToken convertAuthToken(String token) { // String to AuthToken
return new AuthToken(token, key);
}
public static Date getExpiryDate(String expiry) { // String to Date
return new Date(System.currentTimeMillis() + Long.parseLong(expiry));
}
public Authentication getAuthentication(AuthToken authToken) {
if(authToken.validate()) {
Claims claims = authToken.getTokenClaims();
Collection<? extends GrantedAuthority> authorities =
Arrays.stream(new String[]{claims.get(AUTHORITIES_KEY).toString()})
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
User principal = new User(claims.getSubject(), "", authorities);
// 사실상 principal에 저장되는 값은 socialId값과 role뿐(소셜 로그인만 사용하여 password 저장하지 않아 ""로 넣음)
return new UsernamePasswordAuthenticationToken(principal, authToken, authorities);
} else {
throw new TokenValidFailedException();
}
}
}
- 프로토타입 버전에서는 role이 USER뿐이라 위와 같은 코드 형식이지만, 추후 관리자 권한이 필요할 경우 role값에 ADMIN도 들어갈 수 있도록 수정해야 합니다.
- 관리자가 추후 생겨날 예정이라 role을 따로 생성해둔 것입니다.
AuthToken.java
@Slf4j
@RequiredArgsConstructor
public class AuthToken {
@Getter
private final String token;
private final Key key;
private static final String AUTHORITIES_KEY = "role";
AuthToken(String socialId, RoleType roleType, Date expiry, Key key) {
String role = roleType.toString(); // USER, ADMIN
this.key = key;
this.token = createAuthToken(socialId, role, expiry);
}
private String createAuthToken(String socialId, String role, Date expiry) { // AccessToken(AppToken) 생성
return Jwts.builder()
.setSubject(socialId)
.claim(AUTHORITIES_KEY, role)
.signWith(key, SignatureAlgorithm.HS256)
.setExpiration(expiry)
.compact();
}
public boolean validate() { // AccessToken(AppToken) 유효한지 체크
return this.getTokenClaims() != null;
}
public Claims getTokenClaims() {
try {
return Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token)
.getBody(); // token의 Body가 하기 exception들로 인해 유효하지 않으면 각각에 해당하는 로그 콘솔에 찍음
} catch (SecurityException e) {
log.info("Invalid JWT signature.");
} catch (MalformedJwtException e) {
log.info("Invalid JWT token.");
// 처음 로그인(/auth/kakao, /auth/google) 시, AccessToken(AppToken) 없이 접근해도 token validate을 체크하기 때문에 exception 터트리지 않고 catch합니다.
} catch (ExpiredJwtException e) {
log.info("Expired JWT token.");
} catch (UnsupportedJwtException e) {
log.info("Unsupported JWT token.");
} catch (IllegalArgumentException e) {
log.info("JWT token compact of handler are invalid.");
}
return null;
}
}
KakaoAuthService.java & GoogleAuthService.java
@Service
@RequiredArgsConstructor
public class KakaoAuthService { // public class GoogleAuthService
private final ClientKakao clientKakao; // private final ClientGoogle clientGoogle;
private final MemberQuerydslRepository memberQuerydslRepository;
private final AuthTokenProvider authTokenProvider;
private final MemberRepository memberRepository;
@Transactional
public AuthResponse login(AuthRequest authRequest) {
Members kakaoMember = clientKakao.getUserData(authRequest.getAccessToken()); // userData 담기
// Members googleMember = clientGoogle.getUserData(authRequest.getAccessToken());
String socialId = kakaoMember.getSocialId();
// String socialId = googleMember.getSocialId();
Members member = memberQuerydslRepository.findBySocialId(socialId);
AuthToken appToken = authTokenProvider.createUserAppToken(socialId); // 신규 토큰 생성
if (member == null) {
memberRepository.save(kakaoMember);
// memberRepository.save(googleMember);
}
return AuthResponse.builder() // /auth/kakao와 /auth/google의 응답의 body로 AccessToken(AppToken)을 보내주기위해 builder 사용
.appToken(appToken.getToken())
.build();
}
}
- Google과 Kakao의
login 메소드
는변수명
과getUserData 메소드
만 상이하여서 하나의 코드에 주석을 통해 두 코드를 나타냈습니다.- 동일한 코드를 이용해서 할 수 있는 방법이 없을까 고민해보았지만, Google과 Kakao가 전달해주는
UserData
의 형식이 상이하여Dto
를 따로 가져갈 수 밖에 없어 따로 구현하였습니다. - 물론,
getUserData(authRequest.getAccessToken())
를 하나로 가고getUserData
내에서 분기처리를 할 수 있지만SWITCH ~ CASE문
이 아닌 다른 방식이 떠오르지 않아 나누어 구현하였습니다.
- 동일한 코드를 이용해서 할 수 있는 방법이 없을까 고민해보았지만, Google과 Kakao가 전달해주는
ClientKakao.java
@Component
@RequiredArgsConstructor
public class ClientKakao implements ClientProxy {
private final WebClient webClient;
// TODO ADMIN 유저 생성 시 getAdminUserData 메소드 생성 필요
@Override
public Members getUserData(String accessToken) {
KakaoUserResponse kakaoUserResponse = webClient.get()
.uri("https://kapi.kakao.com/v2/user/me") // KAKAO의 유저 정보 받아오는 url
.headers(h -> h.setBearerAuth(accessToken)) // JWT 토큰을 Bearer 토큰으로 지정
.retrieve()
// 아래의 onStatus는 error handling
.onStatus(HttpStatus::is4xxClientError, response -> Mono.error(new TokenValidFailedException("Social Access Token is unauthorized")))
.onStatus(HttpStatus::is5xxServerError, response -> Mono.error(new TokenValidFailedException("Internal Server Error")))
.bodyToMono(KakaoUserResponse.class) // KAKAO의 유저 정보를 넣을 Dto 클래스
.block();
return Members.builder()
.socialId(String.valueOf(kakaoUserResponse.getId()))
.name(kakaoUserResponse.getProperties().getNickname())
.email(kakaoUserResponse.getKakaoAccount().getEmail())
.gender(kakaoUserResponse.getKakaoAccount().getGender())
.memberProvider(MemberProvider.KAKAO)
.roleType(RoleType.USER)
.profileImagePath(kakaoUserResponse.getProperties().getProfileImage())
.build();
}
}
- 기본적으로
Spring Security
와OAuth2-Client
를 사용하여 로그인을 구현하면,Security Config 설정
에서 endpoint를 작성하여 바로 로그인 로직을 구현할 수 있습니다. - 위 내용은, 특정 소셜 API에 대해서만 응답을 받아오기 위해
WebClient
를 사용한 것입니다.
ClientGoogle.java
@Component
@RequiredArgsConstructor
public class ClientGoogle implements ClientProxy {
private final WebClient webClient;
// TODO ADMIN 유저 생성 시 getAdminUserData 메소드 생성 필요
@Override
public Members getUserData(String accessToken) {
GoogleUserResponse googleUserResponse = webClient.get()
.uri("https://oauth2.googleapis.com/tokeninfo", builder -> builder.queryParam("id_token", accessToken).build())
// KAKAO와 달리 GOOGLE을 IdToken을 query parameter로 받습니다. 이로 인해 KAKAO와 uri 작성 방식이 상이합니다.
.retrieve()
.onStatus(HttpStatus::is4xxClientError, response -> Mono.error(new TokenValidFailedException("Social Access Token is unauthorized")))
.onStatus(HttpStatus::is5xxServerError, response -> Mono.error(new TokenValidFailedException("Internal Server Error")))
.bodyToMono(GoogleUserResponse.class)
.block();
return Members.builder()
.socialId(googleUserResponse.getSub())
.name(googleUserResponse.getName())
.email(googleUserResponse.getEmail())
.memberProvider(MemberProvider.GOOGLE)
.roleType(RoleType.USER)
.profileImagePath(googleUserResponse.getPicture())
.build();
}
}
OAuth2-Client
와Spring Security
를 이용하면 Google의 경우는 따로 사용자 정보를 얻을 uri를 알 필요가 없었지만, OAuth2-Client를 사용하지 않아 해당 uri 검색에 어려움이 많았습니다😂
끝내며
프로젝트의 로그인 기능 구현하면서 어려웠던 부분들 위주로 작성했기 때문에 config 클래스, entity 클래스 등 작성하지 않은 항목들도 있습니다. 이 글을 토대로 로그인 기능을 구현하시려 한다면, 위 내용과 더불어 아래의 참고 사이트와 추후 설정 추가 시 참고할 사이트도 함께 봐주시면 좋을 것 같습니다.
참고 사이트
추후 설정 추가 시 참고할 사이트
'FRAMEWORK > Spring' 카테고리의 다른 글
[Enum] enum에 연결된 값을 통해 enum값 알아내기 (0) | 2021.10.26 |
---|---|
[Swagger 2] java.lang.NumberFormatException:For input string: "" exception 해결법 (0) | 2021.10.24 |
[Springboot, IntelliJ] Re-run Spring Boot Configuration Annotation Processor to update generated metadata (0) | 2021.10.04 |
[SpringBoot] 게시판 만들기 v2.2 (머스테치로 화면 구성하기) (1) | 2021.04.25 |
[SpringBoot] 게시판 만들기 v2.1 (등록/수정/조회 API 만들기) (0) | 2021.04.25 |