์„ธ์…˜ ๋กœ๊ทธ์ธ

์ค€๋น„๋œ API

  • /login โ†’ username, code
  • /login/check โ†’ valid, username
  • /logout

์ด๋•Œ, HTTPS ํ†ต์‹ ์„ ํ•˜์ง€ ์•Š์„๊บผ๋ผ๋ฉด API ์ฃผ์†Œ ๋„๋ฉ”์ธ์€ WEB ๋„๋ฉ”์ธ๊ณผ ์ผ์น˜ ์‹œ์ผœ์•ผ ํฌ๋กฌ์—์„œ same-site๋กœ ์ธ์‹ํ•œ๋‹ค. ํฌ๋กฌ์€ ๊ธฐ๋ณธ์œผ๋กœ same-site=Lax๋กœ ์„ค์ •๋˜์–ด์žˆ๋‹ค. ๊ทธ๋ž˜์„œ ๋„๋ฉ”์ธ์ด ๋‹ค๋ฅด๋ฉด ๊นŒ๋‹ค๋กœ์šธ ์ˆ˜ ์žˆ๋‹ค.

CSRF ๋ž€

A ๋ผ๋Š” ์‚ฌ์šฉ์ž๊ฐ€ example.com์— ๋กœ๊ทธ์ธ ๋œ ์ฑ„๋กœ (์œ ํšจํ•œ session id๋ฅผ ์ฟ ํ‚ค๋กœ ๊ฐ€์ง€๊ณ  ์žˆ๋Š”) ์žˆ๋‹ค๋ฉด B(์•…๋‹น) ์ด <img src=http://example.com/api/logout /> ์ด๋Ÿฌํ•œ ํŽ˜์ด์ง€๋ฅผ A ๋ผ๋Š” ์‚ฌ์šฉ์ž์—๊ฒŒ ๋„˜๊ฒจ์„œ A ์‚ฌ์šฉ์ž๊ฐ€ ๋ณด๊ฒŒ ๋œ๋‹ค๋ฉด example๋กœ ์š”์ฒญ์ด ๋“ค์–ด๊ฐˆ๋•Œ session id ์ฟ ํ‚ค์™€ ํ•จ๊ป˜ ์š”์ฒญ์ด ๋“ค์–ด๊ฐ„๋‹ค. ๊ทธ๋Ÿฌ๋ฉด ์œ ํšจํ•œ ์š”์ฒญ์ด ๋˜์–ด์„œ A ์‚ฌ์šฉ์ž๋„ ๋ชจ๋ฅด๊ฒŒ ๋กœ๊ทธ์•„์›ƒ์ด ๋˜๋Š” ๊ฒƒ์ด๋‹ค. ์—ฌ๊ธฐ์„œ๋Š” ๋‹จ์ˆœ ๋กœ๊ทธ์•„์›ƒ ์š”์ฒญ์ด์ง€๋งŒ ์ด ์š”์ฒญ์€ ์•…์˜์ ์ธ ๋ฐ์ดํ„ฐ ์ˆ˜์ง‘, ์ˆ˜์ •, ์‚ญ์ œ๊ฐ€ ๋  ์ˆ˜ ์žˆ๋‹ค.

๋กœ๊ทธ์ธ ํ”„๋กœ์„ธ์Šค

  1. ID, PASSWORD, ๋กœ๊ทธ์ธ ์œ ์ง€ ์œ ๋ฌด ๊ฐ’์„ /login API๋กœ ์ „๋‹ฌ
  2. ์ „์—ญ auth context์— USERNAME, AUTIFICATION ์ €์žฅ ( ์œ ์ผํ•œ ์ง„์‹ค์˜ ๊ทผ์› )
  3. ์ฒ˜์Œ URL ์น˜๊ณ  ๋“ค์–ด์˜จ ๊ณณ์œผ๋กœ Redirect ๋˜๋Š” '/' ๋กœ Redirect

๋กœ๊ทธ์ธ ์œ ์ง€ ํ”„๋กœ์„ธ์Šค ( URL ์ž…๋ ฅ์œผ๋กœ ์œ ์ž… )

  1. ์ „์—ญ auth ์ปจํ…์ŠคํŠธ ์‹คํ–‰์‹œ check api๋ฅผ ํ˜ธ์ถœ
  2. ์‘๋‹ต์ด ์˜ฌ๋•Œ๊นŒ์ง€ null ( ํ™”๋ฉด์— ์•„๋ฌด๊ฒƒ๋„ ์ถœ๋ ฅ ์•ˆํ•จ )
  3. ์‘๋‹ต์ด ์˜ค๋ฉด AUTIFICATION ์— true / false ์…‹ํŒ…
  4. true ์‹œ privateRoute์—์„œ ํ•ด๋‹น Component ๋ณด์—ฌ์คŒ.
  5. false ์‹œ /login ์œผ๋กœ Redirect
  1. ํŽ˜์ด์ง€ ์ด๋™ Link ๋ˆ„๋ฅผ์‹œ check api ํ˜ธ์ถœ ํ›„ ์„ธ์…˜ valid ํ™•์ธ
  2. true ์‹œ 'to' props๋กœ ์ด๋™
  3. false ์‹œ ์„ธ์…˜ ๋งŒ๋ฃŒ ๋ชจ๋‹ฌ์ฐฝ ๋„์›€ ( ๋ชจ๋‹ฌ ํ™•์ธ ์‹œ /login ํŽ˜์ด์ง€๋กœ ์ด๋™ )

์ด๋•Œ, ๋ชจ๋‹ฌ์ฐฝ์€ ์ด์ „์— ๋ณด๊ณ  ์žˆ๋˜ ํ™”๋ฉด์—์„œ ๋„์šฐ๊ธฐ ์œ„ํ•ด์„œ Link ์ปดํฌ๋„ŒํŠธ ํด๋ฆญ์‹œ Auth.check ๋ฅผ ์ง„ํ–‰ํ•œ๋‹ค. Link ์ปดํฌ๋„ŒํŠธ์˜ onClick ์„ prevent ์ง„ํ–‰ ํ›„ Auth.check ๋งŒ์•ฝ PrivateRoute ์—์„œ ์ง„ํ–‰ ์‹œ ์•„๋งˆ๋„ ๋นˆ ํŽ˜์ด์ง€ ํ™”๋ฉด์—์„œ ๋ชจ๋‹ฌ ์ฐฝ์ด ๋œฐ๊ฑฐ ๊ฐ™์•„์„œ ์ด๋ ‡๊ฒŒ ์ง„ํ–‰ ํ–ˆ์—ˆ๋‹ค.

์ œ๊ณตํ•˜๋Š” API ํ˜ธ์ถœ์‹œ ( ํŽ˜์ด์ง€ ๋‚ด )

  1. ์‘๋‹ต์œผ๋กœ 403 error ๋ฐœ์ƒ์‹œ API ๊ณผ ๊ด€๋ จ๋œ ErrorBoundary ์—์„œ ์„ธ์…˜ ๋งŒ๋ฃŒ ๋ชจ๋‹ฌ์ฐฝ ๋„์›€
  2. ๋ชจ๋‹ฌ ํ™•์ธ์‹œ /login ํŽ˜์ด์ง€๋กœ ์ด๋™

๋กœ๊ทธ์•„์›ƒ ํ”„๋กœ์„ธ์Šค

  1. ๋กœ๊ทธ์•„์›ƒ ๋ฒ„ํŠผ ๋ˆ„๋ฅผ ์‹œ 403 error ๋ฐœ์ƒ์‹œ ์„ธ์…˜ ๋งŒ๋ฃŒ ๋ชจ๋‹ฌ์ฐฝ ๋„์›€
  2. ๋ชจ๋‹ฌ ํ™•์ธ์‹œ /login ํŽ˜์ด์ง€๋กœ ์ด๋™

์„œ๋ฒ„ โ†’ ํด๋ผ์ด์–ธํŠธ

  • Access-Control-Allow-Credentials: true ์…‹ํŒ…
  • Access-Control-Allow-Origin: "*" ์ด ์•„๋‹Œ ๊ตฌ์ฒด์ ์ธ ๋„๋ฉ”์ธ์œผ๋กœ ์…‹ํŒ…

    • ์š”์ฒญ ํ—ค๋”์˜ Oringin, Referer๋ฅผ ์ฐธ๊ณ ํ•ด์„œ ์…‹ํŒ…ํ•˜๋ฉด ๋˜์ง€ ์•Š์„๊นŒ ์‹ถ์Œ.
  • API ์‘๋‹ต์œผ๋กœ Set-Cookie http only ์ฟ ํ‚ค๋กœ SESSIONID๋ฅผ ๋ฐ›์Œ

ํด๋ผ์ด์–ธํŠธ โ†’ ์„œ๋ฒ„

  • withCredentials true ์„ค์ •

๊ด€๋ จ ์ฝ”๋“œ

Login API

// login API

export const AuthAPi: IAuthApi = {
  login($elemForm) {
    const formData = new FormData($elemForm);
    formData.set("isKeep", formData.has("isKeep") ? "true" : "false");
    return request({
      url: `${DOMAIN}/login`,
      method: "POST",
      body: formData
    });
  },
  check() {
    return request({
      url: `${DOMAIN}/login/check`,
      method: "GET"
    });
  },
  logout() {
    return request({
      url: `${DOMAIN}/logout`,
      method: "GET"
    });
  }
};

AuthContext

// AuthContext.tsx
type AuthContextProviderProps = {
  children: React.ReactNode;
};
type Action =
  | {
      type: "LOGIN";
      username: string;
    }
  | { type: "LOGIN_CHECK"; username: string; isAuth: boolean }
  | { type: "LOGOUT" };
type AuthDispatch = Dispatch<Action>;

const AuthStateContext = createContext<AuthState>({
  username: "",
  isAuthenticated: false
});

const AuthDispatchContext = createContext<AuthDispatch | undefined>(undefined);

function AuthReducer(state: AuthState, action: Action): AuthState {
  switch (action.type) {
    case "LOGIN":
      return {
        ...state,
        username: action.username,
        isAuthenticated: true
      };
    case "LOGIN_CHECK":
      return {
        ...state,
        username: action.username,
        isAuthenticated: action.isAuth
      };
    case "LOGOUT":
      return {
        ...state,
        username: "",
        isAuthenticated: false
      };
    default:
      throw new Error("Unhandled action");
  }
}

export function AuthContextProvider({ children }: AuthContextProviderProps) {
  const [checkState, subject$] = useApiObservable(AuthAPi.check, false);
  useEffect(() => {
    subject$.next();
  }, [subject$]);

  const [authState, dispatch] = useReducer(AuthReducer, {
    username: "",
    isAuthenticated: false
  });

  const loginCheckSuccess = checkState.success;
  const loginCheckError = checkState.error;
  const loginCheckLoading = checkState.isLoading;

  if (!loginCheckSuccess && !loginCheckError && !loginCheckLoading) return null;

  const loginCheckResponse = (loginCheckSuccess?.response || {
    valid: false
  }) as CheckResponse;

  if (loginCheckResponse.valid) {
    // dispatch({ type: "LOGIN_CHECK", username: "merlin.ho", isAuth: true });
    authState.isAuthenticated = true;
    authState.username = loginCheckResponse.name || "";
  }
  return (
    <AuthDispatchContext.Provider value={dispatch}>
      <AuthStateContext.Provider value={authState}>
        {children}
      </AuthStateContext.Provider>
    </AuthDispatchContext.Provider>
  );
}

export function useAuthState() {
  const state = useContext(AuthStateContext);
  return state;
}

export function useAuthDispatch() {
  const dispatch = useContext(AuthDispatchContext);
  if (!dispatch) throw new Error("AuthDispatchContext value not found");
  return dispatch;
}

Login

// Login.tsx

function Login({ location }: LoginProps) {
  const [loginState, subject$] = useApiObservable(AuthAPi.login, false);
  const authDispatch = useAuthDispatch();

  const onSubmit = (e: React.FormEvent) => {
    if (e.currentTarget !== null) {
      subject$.next(e.currentTarget as HTMLFormElement);
    }
  };

  const resultLoginStatus = loginStatus(loginState);
  if (resultLoginStatus.isLoginSuccess) {
    authDispatch({
      type: "LOGIN",
      username: resultLoginStatus.username
    });
    // ๋ฆฌ๋‹ค์ด๋ ‰ํŠธ
    const { from } = (location.state as { from: { pathname: string } }) || {
      from: { pathname: "/" }
    };
    return <Redirect to={from} />;
    // return <Redirect to={{ ...from, state: { isAuth: true } }} />;
  }

  return (
    <Flex
      sx={{
        flexDirection: "column",
        justifyContent: "center",
        alignItems: "center",
        height: "100%"
      }}
    >
      <Box sx={{ position: "relative", width: "400px" }}>
        <Heading variant="loginLogo" sx={{ mb: 2 }}>
          User Finding Operation
        </Heading>
        <LoginForm
          onSubmit={onSubmit}
          errorMessage={resultLoginStatus.errorMessage}
        ></LoginForm>
        <LoginInfo></LoginInfo>
      </Box>
    </Flex>
  );
}

export default Login;

PrivateRoute

// PrivateRoute.tsx

type PrivateRouteProps = {
  // isLoginCheck: boolean;
} & RouteProps;

function PrivateRoute({
  component: Component,
  // isLoginCheck,
  render,
  ...rest
}: PrivateRouteProps) {
  const { isAuthenticated } = useAuthState();
  return (
    <Route
      {...rest}
      render={({ ...rest }) => {
        if (isAuthenticated) {
          return render
            ? render({ ...rest })
            : Component && <Component {...rest}></Component>;
        }

        return (
          <Redirect
            to={{
              pathname: "/login",
              state: { from: rest.location }
            }}
          ></Redirect>
        );
      }}
    />
  );
}

export default PrivateRoute;
// AuthLink.tsx

type AuthLinkProps = {
  children: React.ReactNode;
} & NavLinkProps &
  RouteComponentProps;

function AuthLink({
  children,
  history,
  to,
  staticContext,
  ...rest
}: AuthLinkProps) {
  const modalDispatch = useModalDispatch();
  // const authDispatch = useAuthDispatch();

  const onClick = (e: React.MouseEvent) => {
    e.preventDefault();

    AuthAPi.check().subscribe(result => {
      const checkSuccess = result.success;
      const responseStatus = checkSuccess?.response as
        | CheckResponse
        | undefined;
      const isLoginSession = responseStatus || { valid: false };
      if (isLoginSession.valid) {
        return history.push(to as string);
      }
      // Todo: result.error๊ฐ€ ๋‚˜์™”์„ ์‹œ์—๋„ ์–ด๋–ค ์ฒ˜๋ฆฌ๋ฅผ ํ•ด๋†”์•ผ..
      modalDispatch({ type: "OPEN", modalType: "SESSION_EXPIRE" });
      // authDispatch({ type: "LOGOUT" });
    });
  };

  return (
    <NavLink to={to} {...rest} onClick={onClick}>
      {children}
    </NavLink>
  );
}

export default withRouter(AuthLink);

Logout

// logout ์ผ๋ถ€

const [logoutState, subject$] = useApiObservable(AuthAPi.logout);

const authDispatch = useAuthDispatch();
const modalDispatch = useModalDispatch();

const logout = (e: React.MouseEvent) => {
  e.preventDefault();
  subject$.next();
};

const logoutSuccess = logoutState.success?.status as number | undefined;
const logoutError = logoutState.error;
if (logoutSuccess === 200) {
  authDispatch({ type: "LOGOUT" });
  return <Redirect to={"/login"} />;
}

if (logoutError?.status === 403) {
  modalDispatch({ type: "OPEN", modalType: "SESSION_EXPIRE" });
}
ยฉ 2021 Merlin.ho, Built with Gatsby