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
- 위의 사이트 접속 후, `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"));
}
}
- `header`가 `Bearer `로 시작하지 않으면, Exception을 발생시킨다.
- `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()` ➡️ 실제로 필터링을 구현하는 곳
- 서블릿 컨테이너가 클라이언트 요청에 대해 필터를 호출할 때마다 실행된다.
- `Authorization Header`에서 Token을 가져온다.
- `FirebaseAuth`를 이용하여 Token을 검증한다.
- `UserDetailsService`에서 사용자 정보를 가져와서 `SecurityContext`에 추가해 준다.
- 인증 실패 시, `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://debaeloper.tistory.com/68
https://programmer93.tistory.com/68