์ค๋น๋ 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 ์ฌ์ฉ์๋ ๋ชจ๋ฅด๊ฒ ๋ก๊ทธ์์์ด ๋๋ ๊ฒ์ด๋ค. ์ฌ๊ธฐ์๋ ๋จ์ ๋ก๊ทธ์์ ์์ฒญ์ด์ง๋ง ์ด ์์ฒญ์ ์
์์ ์ธ ๋ฐ์ดํฐ ์์ง, ์์ , ์ญ์ ๊ฐ ๋ ์ ์๋ค.
๋ก๊ทธ์ธ ํ๋ก์ธ์ค
- ID, PASSWORD, ๋ก๊ทธ์ธ ์ ์ง ์ ๋ฌด ๊ฐ์ /login API๋ก ์ ๋ฌ
- ์ ์ญ auth context์ USERNAME, AUTIFICATION ์ ์ฅ ( ์ ์ผํ ์ง์ค์ ๊ทผ์ )
- ์ฒ์ URL ์น๊ณ ๋ค์ด์จ ๊ณณ์ผ๋ก Redirect ๋๋ '/' ๋ก Redirect
๋ก๊ทธ์ธ ์ ์ง ํ๋ก์ธ์ค ( URL ์ ๋ ฅ์ผ๋ก ์ ์ )
- ์ ์ญ auth ์ปจํ ์คํธ ์คํ์ check api๋ฅผ ํธ์ถ
- ์๋ต์ด ์ฌ๋๊น์ง null ( ํ๋ฉด์ ์๋ฌด๊ฒ๋ ์ถ๋ ฅ ์ํจ )
- ์๋ต์ด ์ค๋ฉด AUTIFICATION ์ true / false ์ ํ
- true ์ privateRoute์์ ํด๋น Component ๋ณด์ฌ์ค.
- false ์ /login ์ผ๋ก Redirect
ํ์ด์ง ์ด๋ ํ๋ก์ธ์ค ( Link ์ด๋ )
- ํ์ด์ง ์ด๋ Link ๋๋ฅผ์ check api ํธ์ถ ํ ์ธ์ valid ํ์ธ
- true ์ 'to' props๋ก ์ด๋
- false ์ ์ธ์ ๋ง๋ฃ ๋ชจ๋ฌ์ฐฝ ๋์ ( ๋ชจ๋ฌ ํ์ธ ์ /login ํ์ด์ง๋ก ์ด๋ )
์ด๋, ๋ชจ๋ฌ์ฐฝ์ ์ด์ ์ ๋ณด๊ณ ์๋ ํ๋ฉด์์ ๋์ฐ๊ธฐ ์ํด์ Link
์ปดํฌ๋ํธ ํด๋ฆญ์ Auth.check
๋ฅผ ์งํํ๋ค.
Link
์ปดํฌ๋ํธ์ onClick
์ prevent
์งํ ํ Auth.check
๋ง์ฝ PrivateRoute
์์ ์งํ ์ ์๋ง๋ ๋น ํ์ด์ง ํ๋ฉด์์ ๋ชจ๋ฌ ์ฐฝ์ด ๋ฐ๊ฑฐ ๊ฐ์์ ์ด๋ ๊ฒ ์งํ ํ์๋ค.
์ ๊ณตํ๋ API ํธ์ถ์ ( ํ์ด์ง ๋ด )
- ์๋ต์ผ๋ก 403 error ๋ฐ์์ API ๊ณผ ๊ด๋ จ๋ ErrorBoundary ์์ ์ธ์ ๋ง๋ฃ ๋ชจ๋ฌ์ฐฝ ๋์
- ๋ชจ๋ฌ ํ์ธ์ /login ํ์ด์ง๋ก ์ด๋
๋ก๊ทธ์์ ํ๋ก์ธ์ค
- ๋ก๊ทธ์์ ๋ฒํผ ๋๋ฅผ ์ 403 error ๋ฐ์์ ์ธ์ ๋ง๋ฃ ๋ชจ๋ฌ์ฐฝ ๋์
- ๋ชจ๋ฌ ํ์ธ์ /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
// 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" });
}