Firebase Login(Spring)

Jay·2023년 4월 10일

셋업하기

셋업하기 수행

secureFile.json 이란 이름으로 설정파일 프로젝트 최상단에 추가

Firebase Config(Bean 생성)

@Slf4j
@Configuration
public class FirebaseInitializer {
    @Bean
    public FirebaseApp firebaseApp() throws IOException {
        log.info("Initializing Firebase. ");
        FileInputStream serviceAccount = new FileInputStream("secureFile.json");
        FirebaseOptions options = FirebaseOptions.builder()
                .setCredentials(GoogleCredentials.fromStream(serviceAccount))
                .build();

        FirebaseApp app = FirebaseApp.initializeApp(options);
        log.info("FirebaseApp initialized " + app.getName());
        return app;
    }

    @Bean
    public FirebaseAuth getFirebaseAuth() throws IOException {
        FirebaseAuth firebaseAuth = FirebaseAuth.getInstance(firebaseApp());
        return firebaseAuth;
    }

}

AuthService

구조

로컬 테스트시에는 OAuth 로그인이 불가능 하기 때문에 로컬 테스트가 가능하게 하기 위한 무엇인가가 필요하다.

InterAuthService에서는 실제 Firebase Login이 아닌 가상의 로그인을 수행해서 API만 테스트 가능하게 만든다.

ProdAuthService에서는 실제 Firebase Login을 수행한다.

AuthService는 Abstract Class로 공통의 인터페이스와 실행을 제공한다.

//로그인 성공시 유저 정보를 리턴해주는 DTO 
@Data
@AllArgsConstructor
public class FirebaseTokenDTO {
    String uid;
    String name;
    String email;
    String pictureUrl;

    public FirebaseTokenDTO(FirebaseToken firebaseToken) {
        this.uid = firebaseToken.getUid();
        this.name = firebaseToken.getName();
        this.email = firebaseToken.getEmail();
        this.pictureUrl = firebaseToken.getPicture();
    }
}
@Slf4j
@Service
@AllArgsConstructor
public abstract class AuthService {
    UserService userService;

    public abstract FirebaseTokenDTO verifyIdToken(String bearerToken);

    public abstract void revokeRefreshTokens(String uid) throws FirebaseAuthException;

    public User loginOrEntry(FirebaseTokenDTO tokenDTO) {
        User user = null;
        try {
            user = userService.getUser(tokenDTO.getEmail());
            if (!user.isActiveUser()) {
                log.error("User \"" + user.getEmail() + "\" is not active user. activating user \"" + user.getEmail() + "\"");
                userService.activateUser(user);
            }
        } catch (CustomException e) {
            log.error("User with email {} was not found in the database, creating user", tokenDTO.getEmail());
            user = userService.addUser(tokenDTO.getEmail());
        }
        return user;
    }
}
@Slf4j
@Service
@Profile("inter")
public class InterAuthService extends AuthService {
    public InterAuthService(UserService userService) {
        super(userService);
    }

    @Override
    public FirebaseTokenDTO verifyIdToken(String bearerToken) {
        return new FirebaseTokenDTO("uid-1", "name-1", "admin@gmail.com", "picture-sample");
    }

    @Override
    public void revokeRefreshTokens(String uid) throws FirebaseAuthException {
        log.info("revoke token : {}", uid);
    }
}
@Slf4j
@Service
@Profile("prod")
public class ProdAuthService extends AuthService {
    FirebaseAuth firebaseAuth;
    public ProdAuthService(FirebaseAuth firebaseAuth, UserService userService) {
        super(userService);
        this.firebaseAuth = firebaseAuth;
    }

    @Override
    public FirebaseTokenDTO verifyIdToken(String bearerToken) {
        try {
            FirebaseToken token = firebaseAuth.verifyIdToken(bearerToken);
            FirebaseTokenDTO tokenDTO = new FirebaseTokenDTO(token);
            return tokenDTO;
        } catch (FirebaseAuthException e) {
            log.error("access token is not usable : {}", e.getMessage());
            throw new CustomException(ErrorCode.AUTHENTICATION_FAILURE, "엑세스 토큰이 유효하지 않습니다.");
        }
    }

    @Override
    public void revokeRefreshTokens(String uid) {
        try {
            firebaseAuth.revokeRefreshTokens(uid);
        } catch (FirebaseAuthException e) {
            log.error("revoke token error : {}", e.getMessage());
            throw new CustomException(ErrorCode.NOT_FOUND_USER, "리프레쉬 토큰을 삭제할 수 없습니다.");
        }
    }
}

해당 예제에서는 loginOrEntry 에서 로그인 실패시 자동 가입을 시켰으나 API 분리시 따로 만들어야 한다.

UserService와 User

@Service
@AllArgsConstructor
public class UserService implements UserDetailsService {

    UserRepository userRepository;

    public User getUser(String uid) {
        return userRepository.findByUid(uid)
                .orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUND_USER, "존재하지 않는 유저입니다."));
    }

    public User getActiveUser(String uid) {
        User user = getUser(uid);
        if (!user.isActiveUser())
            throw new CustomException(ErrorCode.NOT_CORRECT_USER, "비활성화 유저입니다. 다시 로그인 해주세요");
        return user;
    }

    public User addUser(String email) {
        User user = User.builder()
                .email(email)
                .userActiveStatus(UserActiveStatus.ACTIVE)
                .build();
        return userRepository.save(user);
    }

    @Transactional
    public void deleteUser(String email) {
        User user = getUser(email);
        user.deactivateUser();
    }

    @Override
    public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
        return userRepository.findByUid(uid).orElse(null);
    }

    @Transactional
    public void activateUser(User user) {
        user.activateUser();
    }
}
@Entity(name = "users")
@ToString
@Getter
@EntityListeners(AuditingEntityListener.class)
public class User implements UserDetails {
    @Id
    @GeneratedValue
    Long userEntryNo;

    @Column
    String email;

    @CreatedDate
    Date registeredDate;

    @Column
    UserActiveStatus userActiveStatus;

    @Getter
    @Setter
    @Transient
    String uid;

    @Builder
    public User(Long userEntryNo, String email, Date registeredDate, UserActiveStatus userActiveStatus) {
        this.userEntryNo = userEntryNo;
        this.email = email;
        this.registeredDate = registeredDate;
        this.userActiveStatus = userActiveStatus;
    }

    public User() {
    }

    public boolean isActiveUser(){
        if (this.userActiveStatus == UserActiveStatus.ACTIVE)
            return true;
        return false;
    }

    public void activateUser() {
        this.userActiveStatus = UserActiveStatus.ACTIVE;
    }

    public void deactivateUser() {
        this.userActiveStatus = UserActiveStatus.NOT_ACTIVE;
    }

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

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

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

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

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

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

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

UserServiceUser 엔티티는 각각 UserDetailsServiceUserService 를 상속받았는데 이는 Filter에서 유저 객체를 사용하는데 필요하다.

Login API 만들기

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

    @Autowired
    UserService userService;

    @Autowired
    AuthService authService;

    @GetMapping("/me")
    public ResponseEntity<Map<String, String>> login(@RequestHeader("Authorization") String token) {
        FirebaseTokenDTO tokenDTO = authService.verifyIdToken(token);
        authService.loginOrEntry(tokenDTO);

        Map<String, String> respMap = new HashMap<>();
        respMap.put("email", tokenDTO.getEmail());
        respMap.put("userImageUrl", tokenDTO.getPictureUrl());
        return ResponseEntity.ok()
                .header(HttpHeaders.SET_COOKIE, createCookie(AuthConsts.accessTokenKey, token).toString())
                .body(respMap);
    }

    @DeleteMapping("/me")
    public ResponseEntity<String> logout() {
        return ResponseEntity.ok()
                .header(HttpHeaders.SET_COOKIE, removeCookie(AuthConsts.accessTokenKey).toString())
                .build();
    }

		public ResponseCookie createCookie(String key, String value){
        return ResponseCookie.from(key, value)
                .maxAge(1 * 60 * 60 * 24 * 365)
                .sameSite("Lax")
                .httpOnly(true)
                .secure(true)
                .path("/")
                .build();
    }

    public ResponseCookie removeCookie(String key){
        return ResponseCookie.from(key, "")
                .maxAge(0)
                .sameSite("Lax")
                .httpOnly(true)
                .secure(true)
                .path("/")
                .build();
    }
}

Authorization Header의 토큰을 검증하고, 로그인 성공시 cookie에 토큰을 넣어 매 요청시 토큰을 헤더에 넣을 필요 없게 만든다.

AuthFilter와 Security Config

해당 유저가 로그인을 했는지 안했는지 확인을 하기 위한 Filter와 Security Config이다.

@Slf4j
public class FirebaseTokenFilter extends OncePerRequestFilter {

    AuthService authService;
    UserDetailsService userDetailsService;

    public FirebaseTokenFilter(AuthService authService, UserDetailsService userDetailsService) {
        this.authService = authService;
        this.userDetailsService = userDetailsService;
    }

    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        Cookie[] cookies = request.getCookies();
        String token = findCookie(cookies, AuthConsts.accessTokenKey);

        try {
            FirebaseTokenDTO tokenDTO = authService.verifyIdToken(token);
            String uid = authService.verifyIdToken(token).getUid();
            User user = (User) userDetailsService.loadUserByUsername(uid);
            UsernamePasswordAuthenticationToken authenticationToken =
                    new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities());
            SecurityContextHolder.getContext().setAuthentication(authenticationToken);
        } catch (Exception e) {
            setUnauthorizedResponse(response, "INVALID_TOKEN");
            return;
        }

        filterChain.doFilter(request, response);
    }

    protected String findCookie(Cookie[] cookies, String cookieName) {
        for (Cookie c : cookies) {
            if (c.getName().equals(cookieName)) {
                log.info(cookieName + " : " + c.getValue());
                return c.getValue();
            }
        }
        return null;
    }

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

}

doFilterInternal 에서 실제 요청이 Controller에서 처리되기 전 전처리 부분을 수행한다.

cookie 에서 로그인 토큰을 가져와 유저정보를 UserService에서 가져온다.

            UsernamePasswordAuthenticationToken authenticationToken =
                    new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities());
            SecurityContextHolder.getContext().setAuthentication(authenticationToken);

부분은 로그인한 유저를 SecurityContextHolder에 넣는 부분이다. Controller에서 접근이 가능하다.

@Configuration
@AllArgsConstructor
public class SecureConfig {

    private UserDetailsService userDetailsService;

    private AuthService authService;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        setSecurityConfigs(http);

        http.addFilterBefore(new FirebaseTokenFilter(authService, userDetailsService),
                UsernamePasswordAuthenticationFilter.class);
        return http.build();
    }

    public void setSecurityConfigs(HttpSecurity http) throws Exception {
        http
                .httpBasic().disable()
                .formLogin().disable()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .authorizeRequests(authorize -> authorize.anyRequest().permitAll())
                .csrf(csrf -> {});
    }

    @Bean
    public WebSecurityCustomizer webSecurityCustomizer() {
        return web -> web.ignoring(requestMatchers -> requestMatchers.antPatterns(
            HttpMethod.GET, "/users/me",
            "/favicon.ico",
            "/hello",
            HttpMethod.GET, "/stores/*/reviews",
            HttpMethod.GET, "/stores",
            HttpMethod.GET, "/stores/*"
        ));
    }
}

인증 무시 URL에는 회원가입과 로그인 URL이 들어가야 한다. 해당 url들은 filter를 거치지 않는다.

인증 객체 사용하기

@RestController
@RequestMapping("/stores/{storeId}/reviews")
public class ReviewController {

    @PostMapping("")
    public ResponseEntity<String> addReview(@RequestBody ReviewCreationReqDTO creationDto,
                                            @PathVariable String storeId,
                                            @AuthenticationPrincipal User user){

    }

@AuthenticationPrincipal 를 통해 컨트롤러에서 Filter에서 처리한 User를 가져올 수 있다.

0개의 댓글