본문 바로가기
Projects/OpenRoadmaps

[OpenRoadmaps] 3. 이메일 및 OAuth2 로그인 구현

by DevJaewoo 2022. 11. 28.
반응형

Intro

사용자가 회원가입 및 로그인하는 방법으로 이메일 / OAuth2 두가지가 있다.

위 기능을 구현하며 중요했던 부분들을 정리해보자.


이메일 로그인

Frontend 입력값 검증

useForm Hook을 사용하여 입력값을 검증했다.

regex 형식으로 검증하며, 패턴에 일치하지 않을 시 input 아래에 에러 메시지가 뜨도록 구현했다.

<form
  onSubmit={handleSubmit(onSubmit)}
  className="flex flex-col w-full max-w-2xl mt-5 p-10 border-gray-100 border rounded-xl shadow-md"
>
  <div className="w-full mb-5">
    <h3 className="text-lg">이메일</h3>
    <input
      type="email"
      placeholder="example@email.com"
      className="w-full mt-2 p-3 border border-gray-300 rounded-md"
      {...register("email", {
        required: "이메일을 입력해주세요.",
        pattern: {
          value: /\S+@\S+/,
          message: "이메일 형식이 올바르지 않습니다.",
        },
      })}
    />
    <PatternError field={errors.email as FieldError} />
  </div>
</form>

 

로그인 UI

 

로그인 요청 API

세션으로 사용자를 인증하기 때문에 로그인 시 쿠키를 받고, 이후 요청마다 해당 쿠키를 전송해야 한다.

쿠키와 함께 요청하기 위해 withCredentialstrue로 설정해야 하는데, 모든 요청에 해당 속성을 넣는건 번거로우니 true로 설정되어있는 axiosInstance를 만들었다.

import axios from "axios";

const axiosInstance = axios.create({
  withCredentials: true,
});

export default axiosInstance;

 

비동기로 로그인 요청을 보내는 API 요청 함수를 만들었다.

Request 관련 데이터 관리를 편하게 하기 위해 React Query를 사용했다.

interface LoginRequest {
  email: string;
  password: string;
}

const fetchLogin = async (request: LoginRequest): Promise<ClientInfo> => {
  const response = await axiosInstance.post("/api/v1/client/login", request);
  return response.data;
};

const useLogin = () => {
  const [, setClientInfo] = useRecoilState(atomClientInfo);
  return useMutation(fetchLogin, {
    onSuccess: (data) => {
      setClientInfo(data);
    },
  });
};

export default useLogin;

 

API 요청을 수행할 함수에서 아래와 같이 사용하면 된다.

const { isLoading, isError, error, mutate } = useLogin();

const onSubmit: SubmitHandler<FieldValues> = (data) => {
  if (isLoading) return;
  mutate({
    email: data.email,
    password: data.password,
  });
};

 

Backend 세션 관리

로그인 시 생성자 주입을 통해 가져온 HttpSession에 Client ID와 Name을 저장하는 SessionClient를 저장한다.

또한 Client Role 정보를 받아 Authority를 부여한 후, SecurityContext에 저장하여 SecurityFilter에서 Unauthorized 처리되지 않게 한다.

private final HttpSession httpSession;

public ClientDto login(ClientDto.Register request) {

    // 강제 로그아웃
    logout();

    ...

    // SessionAttribute에 Client 정보 저장
    if(httpSession != null) {
        httpSession.setAttribute(SessionConfig.CLIENT_INFO, new SessionClient(client));
    }

    // Client 권한 부여
    grantAuthority(client);

    return new ClientDto(client);
}

private void grantAuthority(Client client) {
    Set<SimpleGrantedAuthority> authorities = Collections.singleton(new SimpleGrantedAuthority(client.getRole().key));
    User principal = new User(client.getName(), client.getPassword(), authorities);
    UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(principal, "", authorities);
    SecurityContextHolder.getContext().setAuthentication(authenticationToken);
}

 

SecurityFilter 설정

OAuth에 사용되는 /login/oauth, 이메일 로그인에 사용되는 /api/*/client/register /api/*/client/login 경로는 요청을 허용하도록 설정했다.

기본적으로 인증되지 않은 사용자도 읽기는 가능하도록 설정할 것이기 때문에, GET API 요청은 허용했다.

나머지는 Client Role이 필요하도록 설정하고, Admin API의 경우 Admin Role을 가진 경우만 접근 가능하도록 설정했다.

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http
            .csrf().disable()
            .formLogin().disable()
            .exceptionHandling()
                .authenticationEntryPoint(unauthorizedHandler)
                .accessDeniedHandler(forbiddenHandler).and()
            .authorizeRequests()
                .antMatchers("/login/**").permitAll()
                .antMatchers("/oauth/**").permitAll()
                .antMatchers("/api/*/client/register", "/api/*/client/login").permitAll()
                .antMatchers("/api/*/client/logout").hasAnyRole(Role.CLIENT.name())
                .antMatchers("/api/admin/**").hasAnyRole(Role.ADMIN.name())
                .antMatchers(HttpMethod.GET, "/api/**").permitAll()
                .antMatchers("/api/**").hasAnyRole(Role.CLIENT.name())
                .anyRequest().authenticated().and()
            .oauth2Login()
                .authorizationEndpoint().baseUri("/oauth/authorization").and()
                .redirectionEndpoint().baseUri("/login/oauth2/code/**").and()
                .userInfoEndpoint().userService(customOAuth2UserService).and()
                .successHandler(oAuth2SuccessHandler);

    return http.build();
}

 

만약 인증되지 않은 사용자가 인증이 필요한 URL에 접근하는 경우 아래의 Handler를 통해 Unauthorized 응답을 받는다.

@Component
@RequiredArgsConstructor
public class UnauthorizedHandler implements AuthenticationEntryPoint {

    private final ObjectMapper objectMapper;

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException {
        CommonErrorCode errorCode = CommonErrorCode.UNAUTHORIZED;
        ErrorResponse errorResponse = new ErrorResponse(errorCode);
        String json = objectMapper.writeValueAsString(errorResponse);

        response.setStatus(errorCode.httpStatus.value());
        response.getWriter().write(json);
        response.flushBuffer();
    }
}

 

인증은 되었지만 권한이 없는 경우, 아래의 Handler를 통해 Forbidden 응답을 받는다.

@Component
@RequiredArgsConstructor
public class ForbiddenHandler implements AccessDeniedHandler {

    private final ObjectMapper objectMapper;

    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException {
        CommonErrorCode errorCode = CommonErrorCode.FORBIDDEN;
        ErrorResponse errorResponse = new ErrorResponse(errorCode);
        String json = objectMapper.writeValueAsString(errorResponse);

        response.setStatus(errorCode.httpStatus.value());
        response.getWriter().write(json);
        response.flushBuffer();
    }
}

OAuth2 로그인

Spring Boot에서 OAuth2 Client 기능을 지원해주기 때문에, application.yml에 id와 secret을 넣어주기만 하면 된다.

security:
  oauth2:
    client:
      registration:
        google:
          client-id: client-id
          client-secret: client-secret
          scope: profile, email

 

정상적인 id와 secret 값이 설정되었다면, /oauth/authorization/[google | github]로 이동하고, OAuth 로그인 완료 시 아래의 Service가 호출된다.

사용자 정보를 추출하고 저장하며, 권한을 부여하는 역할을 한다.

public class CustomOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {

    private final ClientService clientService;

    @Override
    @Transactional
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        OAuth2UserService<OAuth2UserRequest, OAuth2User> delegate = new DefaultOAuth2UserService();
        OAuth2User oAuth2User = delegate.loadUser(userRequest);

        String registrationId = userRequest.getClientRegistration().getRegistrationId();
        String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName();

        // OAuth2 사용자 정보 추출
        OAuth2Attributes attributes = OAuth2Attributes.of(registrationId, userNameAttributeName, oAuth2User.getAttributes());

        log.info("Save OAuth2 user {}", oAuth2User.getAttributes());

        // 사용자 저장
        ClientDto clientDto = clientService.registerOAuth(attributes);

        // 권한 부여
        return new DefaultOAuth2User(Collections.singleton(new SimpleGrantedAuthority(clientDto.role().key)), attributes.getAttributes(), userNameAttributeName);
    }
}

세션 관리

아래와 같이 현재 세션 정보를 받아오는 Util 함수를 작성했다.

 

public class SessionUtil {

    public static Optional<SessionClient> getOptionalCurrentClient(){
        ServletRequestAttributes requestAttributes = (ServletRequestAttributes) RequestContextHolder.currentRequestAttributes();
        HttpSession session = requestAttributes.getRequest().getSession(false);
        if(session != null) {
            return Optional.ofNullable((SessionClient) session.getAttribute(SessionConfig.CLIENT_INFO));
        }
        else {
            return Optional.empty();
        }
    }

    public static SessionClient getCurrentClient() {
        return getOptionalCurrentClient().orElseThrow(() -> new RestApiException(CommonErrorCode.UNAUTHORIZED));
    }
}

 

아래와 같이 편리하게 세션 정보를 받아올 수 있다.

@GetMapping("")
@PreAuthorize("hasRole('CLIENT')")
public ResponseEntity<?> getCurrentClientInfo() {
    SessionClient currentClient = SessionUtil.getCurrentClient();

    ClientDto clientDto = clientService.findClientById(currentClient.getId());
    return ResponseEntity.ok(new ClientDto.Response(clientDto));
}

 

반응형