Project/동방역검

[Firebase][1] Firebase를 이용해서 구글 간편 로그인 구현하기

chocoji 2024. 1. 31. 16:15
지난 게시글에서 OAuth에 대해서 알아보았으니, 이제 구글 간편 로그인을 구현해보자!

그런데! 그냥 구현하는게 아니라 `Firebase Authentication`을 추가로 적용할 것이다.


Firebase Authentication

Google, 이메일, 휴대폰 등의 인증 방식을 쉽게 자신의 서비스에 붙일 수 있도록 도와주는 SaaS 서비스
  • Firebase Authentication은 OAuth2의 Authrorization Server 역할을 수행한다.
  • 또한 Client에서 Authorization Server를 통해 인증하는 로직, Resource Server에서 Authorization Server에 접근하는 로직을 제공한다.

2~5 로직과 7~8 로직을 Firebase 라이브러리에서 제공하여, Firebase Authentication을 이용하면 OAuth2를 쉽게 적용할 수 있다.

➡️ 이제 Spring Boot로 Resource Server를 구현해서 6 ~ 9번 과정만 구현해주면 된다.

 

프로젝트 세팅

Firebase Admin 설치

`Firebase Admin`: Firebase에서 온 인증 토큰을 검증할 수 있는 기능을 가지고 있는 라이브러리 
// Firebase
implementation group: 'com.google.firebase', name: 'firebase-admin', version: '8.1.0'

// Spring Security
implementation 'org.springframework.boot:spring-boot-starter-security'
  • Spring 프로젝트 `build.gradle`에 코드 추가

 

Firebase SDK Key 다운로드

https://console.firebase.google.com

 

로그인 - Google 계정

이메일 또는 휴대전화

accounts.google.com

  • 위의 사이트 접속 후, `Firebase 프로젝트 페이지` → `프로젝트 설정` → `서비스 계정` → `Firebase Admin SDK` → `시작하기` → `새 비공개 키 생성`의 과정을 통해 json 파일을 다운로드 받기

 

  • 위와 같이 `resources/`에 저장한 뒤 (git에 안 올라가도록 주의!)

 

firebase:
  config:
    path: "src/main/resources/serviceAccountKey.json"
  • application.yml(또는 application.properties)에 위와 같이 지정해주기!

 

Firebase 초기화, 인증 토큰 검증

  • Firebase 사용하기 위한 Firebase Admin SDK 초기화와 설정 담당 클래스
import java.io.FileInputStream;
import java.io.IOException;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import com.google.auth.oauth2.GoogleCredentials;
import com.google.firebase.FirebaseApp;
import com.google.firebase.FirebaseOptions;
import com.google.firebase.auth.FirebaseAuth;

import lombok.extern.slf4j.Slf4j;

@Slf4j
@Configuration
public class FirebaseConfig {

	@Value("${firebase.config.path}")
	private String firebaseConfigPath;

	@Bean
	public FirebaseApp firebaseApp() throws IOException {
		FileInputStream serviceAccount = new FileInputStream(firebaseConfigPath);
		FirebaseOptions options = FirebaseOptions.builder()
			.setCredentials(GoogleCredentials.fromStream(serviceAccount))
			.build();

		return FirebaseApp.initializeApp(options);
	}

	@Bean
	public FirebaseAuth getFirebaseAuth() {
		try {
			return FirebaseAuth.getInstance(firebaseApp());
		} catch (IOException e) {
			throw new RuntimeException(e);
		}
	}

}
  • 설정 파일에서 지정했던 `firebaseConfigPath`를 지정해 준다.

 

UserService 생성

  • Firebase의 `uid`를 이용하여 구분하고, (프로젝트에 필요한) `birth_date`, `profile_path`를 저장하기 위해 `CustomUser` 객체를 생성함 

CustomUser

import java.util.Collection;

import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.Table;

import org.hibernate.annotations.SQLDelete;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;

@Getter
@Entity
@ToString
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = "user")
@SQLDelete(sql = "UPDATE user SET is_deleted = true WHERE id = ?")
public class CustomUser implements UserDetails {

	@Id
	@GeneratedValue(strategy = GenerationType.IDENTITY)
	@Column(name = "id")
	private Long id;

	@Column(name = "uid", nullable = false)
	private String uid;

	@Column(name = "birth_date", nullable = false)
	private String birthDate;

	@Column(name = "profile_path")
	private String profilePath;

	@Column(name = "is_deleted", nullable = false)
	private Boolean isDeleted = false;

	public CustomUser(String uid, String birthDate) {
		this.uid = uid;
		this.birthDate = birthDate;
	}

	public void updateProfileImage(String profilePath) {
		this.profilePath = profilePath;
	}

	@Override
	public Collection<? extends GrantedAuthority> getAuthorities() {
		return null;
	}

	@Override
	public String getPassword() {
		return null;
	}

	@Override
	public String getUsername() {
		return uid;
	}

	@Override
	public boolean isAccountNonExpired() {
		return false;
	}

	@Override
	public boolean isAccountNonLocked() {
		return false;
	}

	@Override
	public boolean isCredentialsNonExpired() {
		return false;
	}

	@Override
	public boolean isEnabled() {
		return false;
	}

}
  • `UserDetails`: Spring Security에서 사용자의 정보를 담는 인터페이스
    • Spring Security에서 사용자의 정보를 불러오기 위해서 구현해야 하는 인터페이스
  • `getUsername()`: 계정의 고유한 값을 리턴하는 메서드
    • Firebase Authentication을 사용해서 Firebase의 `uid`를 사용하여 유저를 구분하므로 반환값으로 `uid`를 반환해주어야 한다.

CustomUserRepository

import java.util.Optional;

import org.springframework.data.jpa.repository.JpaRepository;

public interface CustomUserRepository extends JpaRepository<CustomUser, Long> {
	Optional<CustomUser> findByUid(String uid);
}
  • `uid`를 입력 받았을 때 해당하는 `CustomUser` 객체를 반환해 준다.

CustomUserService

import org.springframework.http.HttpMethod;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.client.RestTemplate;

import com.game.domain.user.CustomUser;
import com.game.domain.user.CustomUserRepository;

import lombok.RequiredArgsConstructor;

@Service
@RequiredArgsConstructor
public class CustomUserService implements UserDetailsService {

	private final CustomUserRepository customUserRepository;

	@Override
	public UserDetails loadUserByUsername(String uid) throws UsernameNotFoundException {
		return customUserRepository.findByUid(uid).get();
	}

	public CustomUser findByUid(String uid) throws UsernameNotFoundException {
		return customUserRepository.findByUid(uid).get();
	}

	@Transactional
	public CustomUser createUser(String uid, String birthDate) {
		CustomUser customUser = new CustomUser(uid, birthDate);
		customUserRepository.save(customUser);
		return customUser;
	}

	@Transactional
	public String updateProfileImage(Long id) {
		CustomUser customUser = customUserRepository.findById(id)
			.orElseThrow(() -> new IllegalArgumentException("해당 유저가 없어요."));

		String birthDate = customUser.getBirthDate();
		String profilePath = sendProfileImageRequest(birthDate);
		customUser.updateProfileImage(profilePath.replace("\\"", "")); // 리턴 값이 "http:// ... " 형태로 와서 쌍따옴표를 제거하기 위함

		return customUser.toString();
	}

	private String sendProfileImageRequest(String birthDate) {
		String url = "<https://k8a305.p.ssafy.io/profile/?birth=>" + birthDate;

		RestTemplate restTemplate = new RestTemplate();
		ResponseEntity response = restTemplate.exchange(url, HttpMethod.POST, null, String.class);

		return response.getBody();
	}

}
  • `loadUserByUsername(String uid)`: `uid`를 기준으로 `UserDetails`를 리턴한다.
    • FirebaseTokenFilter에서 사용하기 위함
  • `findByUid(String uid)`: `uid`를 기준으로 `CustomUser`를 리턴한다. 
    • 실제 DB에 접근하기 위함
  • `createUser(String uid, String birthDate)`: `uid`와 `birthDate`를 입력 받아 `CustomUser`를 생성한다.
  • `updateProfileImage(Long id)`: 유저의 고유키(id)를 입력 받아 프로필 이미지를 업데이트 한다.
  • `sendProfileImageRequests(String birthDate)`: `birthDate`를 인자로 프로필 사진을 요청한다.

UserController

import org.springframework.http.HttpStatus;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;

import com.game.domain.user.CustomUser;
import com.game.message.RegisterInfo;
import com.game.message.UserInfo;
import com.game.service.CustomUserService;
import com.game.utils.DateUtil;
import com.game.utils.RequestUtil;
import com.google.firebase.auth.FirebaseAuth;
import com.google.firebase.auth.FirebaseAuthException;
import com.google.firebase.auth.FirebaseToken;

import io.swagger.annotations.ApiOperation;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

@Slf4j
@RestController
@RequestMapping("/users")
@RequiredArgsConstructor
public class UserController {

	private final FirebaseAuth firebaseAuth;
	private final CustomUserService userService;

	@ApiOperation(value = "토큰 추출해서 사용자 등록")
	@PostMapping("/register")
	public UserInfo register(@RequestHeader("Authorization") String authorization,
		@RequestBody RegisterInfo registerInfo) {
		FirebaseToken decodedToken;
		try {
			// Token 추출
			String token = RequestUtil.getAuthorizationToken(authorization);
			decodedToken = firebaseAuth.verifyIdToken(token);
		} catch (IllegalArgumentException | FirebaseAuthException e) {
			throw new ResponseStatusException(HttpStatus.UNAUTHORIZED,
				"{\\"code\\":\\"INVALID_TOKEN\\", \\"message\\":\\"" + e.getMessage() + "\\"}");
		}
		CustomUser registeredUser = userService.createUser(
			decodedToken.getUid(), DateUtil.convertDateFormat(registerInfo.getBirthDate())
		);

		Long userId = registeredUser.getId();
		userService.updateProfileImage(userId);
		return new UserInfo(registeredUser);
	}

	@ApiOperation(value = "개인 정보 조회")
	@GetMapping("/myInfo")
	public UserInfo getMyInfo(Authentication authentication) {
		CustomUser customUser = ((CustomUser)authentication.getPrincipal());
		return new UserInfo(customUser);
	}

	@ApiOperation(value = "uid 입력 시 userId(pk) 반환")
	@GetMapping
	public Long findByUserId(@RequestParam String uid) {
		return userService.findByUid(uid).getId();
	}
}
  • `POST /users/register`: 토큰을 추출해서 DB에 사용자 등록. 이때 바로 DALI에 요청해서 프로필 사진 얻어 옴
  • `GET /users/myInfo`: 토큰을 추출해서 개인 정보 조회
  • `GET /users`: `uid` 입력 시 `user Id(PK)` 반환

 

RequestUtil

  • Authorization 헤더로 전달된 토큰 추출
import javax.servlet.http.HttpServletRequest;

public class RequestUtil {

	public static String getAuthorizationToken(String header) {
		if (!header.startsWith("Bearer ")) {
			throw new IllegalArgumentException("Invalid authorization header");
		}
		// Remove Bearer from string
		String[] parts = header.split(" ");
		if (parts.length != 2) {
			throw new IllegalArgumentException("Invalid authorization header");
		}
		// Get token
		return parts[1];
	}

	public static String getAuthorizationToken(HttpServletRequest request) {
		return getAuthorizationToken(request.getHeader("Authorization"));
	}
}
  1.  `header`가 `Bearer `로 시작하지 않으면, Exception을 발생시킨다.
  2. `header`가 정상적이라면, `" "` 기준으로 분리하여 `Bearer`를 제거한다.
    • 이때 2개로 분리되지 않는다면, 유효하지 않은 Access Token이므로 Exception 발생

 

Filter에서 인증 토큰 검증하기

Firebase IDToken 인증

  • 사용자 요청이 들어오면 Controller에 접근하기 전에, Request를 인터셉트해서 전처리 역할 및 후처리 역할을 할 수 있다.
  • Spring Security 설정과 결합하여 특정 Request와 결합했을 때만 사용자 요청을 처리할 수 있다.

FirebaseTokenFIlter

import java.io.IOException;
import java.util.NoSuchElementException;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.apache.http.HttpStatus;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.web.filter.OncePerRequestFilter;

import com.game.domain.user.CustomUser;
import com.google.firebase.auth.FirebaseAuth;
import com.google.firebase.auth.FirebaseAuthException;
import com.google.firebase.auth.FirebaseToken;

import lombok.RequiredArgsConstructor;

@RequiredArgsConstructor
public class FirebaseTokenFilter extends OncePerRequestFilter {

	private final UserDetailsService userDetailsService;
	private final FirebaseAuth firebaseAuth;

	@Override
	protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
		throws ServletException, IOException {

		// get the token from the request
		FirebaseToken decodedToken;
		String header = request.getHeader("Authorization");
		if (header == null || !header.startsWith("Bearer ")) {
			setUnauthorizedResponse(response, "INVALID_HEADER");
			return;
		}
		String token = header.substring(7);

		// verify IdToken
		try {
			decodedToken = firebaseAuth.verifyIdToken(token);
		} catch (FirebaseAuthException e) {
			setUnauthorizedResponse(response, "INVALID_TOKEN");
			return;
		}

		// User를 가져와 SecurityContext에 저장한다.
		try {
			UserDetails user = userDetailsService.loadUserByUsername(decodedToken.getUid());

			if (user != null) {
				UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
					user, null, user.getAuthorities());
				SecurityContextHolder.getContext().setAuthentication(authentication);
			} else {
				CustomUser customUser = new CustomUser(decodedToken.getUid(), null);
				user = customUser;
				UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
					user, null, user.getAuthorities());
				SecurityContextHolder.getContext().setAuthentication(authentication);
			}
		} catch (NoSuchElementException e) {
			setUnauthorizedResponse(response, "USER_NOT_FOUND");
			return;
		}
		filterChain.doFilter(request, response);
	}

	private void setUnauthorizedResponse(HttpServletResponse response, String code) throws IOException {
		response.setStatus(HttpStatus.SC_UNAUTHORIZED);
		response.setContentType("application/json");
		response.getWriter().write("{\\"code\\":\\"" + code + "\\"}");
	}

}
  • `OncePerRequestFilter`
    • 각 요청마다 단 한번만 필터링 작업을 수행하기 위해 설계되었으며, 모든 서블릿에 일관된 요청을 처리하기 위해 만들어진 필터
  • `doFilterInternal()` ➡️ 실제로 필터링을 구현하는 곳
    • 서블릿 컨테이너가 클라이언트 요청에 대해 필터를 호출할 때마다 실행된다.
  1. `Authorization Header`에서 Token을 가져온다.
  2. `FirebaseAuth`를 이용하여 Token을 검증한다.
  3. `UserDetailsService`에서 사용자 정보를 가져와서 `SecurityContext`에 추가해 준다.
  4. 인증 실패 시, `HttpStatus 401`과 json으로 code를 response한다.

 

SpringConfig 코드

import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.builders.WebSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.web.authentication.HttpStatusEntryPoint;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

import com.game.filter.FirebaseTokenFilter;
import com.game.service.CustomUserService;
import com.google.firebase.auth.FirebaseAuth;

import lombok.RequiredArgsConstructor;

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
@EnableGlobalMethodSecurity(securedEnabled = true, jsr250Enabled = true, prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {

	private final UserDetailsService userDetailsService;
	private final FirebaseAuth firebaseAuth;

	/**
	 * HttpRequest를 받는 부분에 filter 적용 (addFilterBefore)
	 */
	@Override
	public void configure(HttpSecurity http) throws Exception {
		http.cors().and()
			.authorizeRequests()
				.antMatchers(HttpMethod.POST, "/users/register", "/users/register/").permitAll()
				.antMatchers(HttpMethod.POST, "/users/profile-update").permitAll()
				.antMatchers(HttpMethod.GET, "/users", "/users").permitAll()
				.anyRequest().authenticated()
				.and()
			.addFilterBefore(new FirebaseTokenFilter(userDetailsService, firebaseAuth),
				UsernamePasswordAuthenticationFilter.class)
			.exceptionHandling()
			.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED));
	}

	/**
	 * WebSecurity를 받는 부분에서 Filter를 적용하지 않을 요청
	 */
	@Override
	public void configure(WebSecurity web) throws Exception {
		// 회원가입, 메인페이지
		web.ignoring()
			.antMatchers(HttpMethod.POST, "/users/register")
			.antMatchers("/");
	}

}
  • `HttpRequest`를 받는 부분에 Filter를 적용(`addFilterBefore`)하고, `WebSecurity`를 받는 부분에서 Filter를 적용하지 않을(`ignoring`) 요청을 추가한다.
  • `ignoring`하지 않은 모든 요청은 `FirebaseTokenFilter`에서 토큰 검증을 수행한다. 

 

여기까지 Spring Boot를 활용한 Firebase 토큰 검증 Resoucre Server 구현 끝!
전체 코드는 GitHub → 참고!

 

Ref

https://velog.io/@couchcoding/Firebase%EB%A1%9C-Google-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0-Spring-React-%EC%98%88%EC%A0%9C

https://debaeloper.tistory.com/68

https://programmer93.tistory.com/68