언어: Typescript
프레임워크: Nextjs(14)
라이브러리: axios, cookie, js-cookie, uuid
1. 사용자가 웹사이트에서 Google 로그인 버튼을 클릭합니다.
2. 클라이언트가 Google 인증 서버에 인증을 요청합니다.
3. Google이 사용자에게 로그인 페이지를 보여줍니다.
4-5. 사용자가 Google 계정으로 로그인하고 권한을 승인합니다.
6. Google이 클라이언트에게 인증 코드를 전달합니다.
7-8. 서버가 이 코드와 Client Secret을 사용하여 Access Token을 요청합니다.
9-11. Access Token으로 사용자 정보를 받아옵니다.
12-13. 서버가 JWT 토큰을 발급하고 로그인이 완료됩니다.
*구현은 11번까지로 AccessToken을 발급받아 전달하는 것까지의 과정입니다.
- 로그인 요청페이지는 로그인 url 생성에 필요한 파라미터를 서버에 전달 및 cookie에 특정값을 저장한다.
- 파라미터를 가지고 "/api/authInfo" 를 호출 결과로 받은 url 주소로 redirect 시킨다.
- cookie에 저장하는 이유는 url 생성시 사용된 파라미터가 구글 로그인후 token 요청시 필요하기 때문이다.
export default function SignInAuto() { const signInWithGoogle = () => { Cookie.set("state", state); Cookie.set("codeVerifier", codeVerifier); Cookie.set("codeChallenge", codeChallenge); apiClient .get("/api/authInfo") .then((res) => { Cookie.set("clientId", res.data.clientId); Cookie.set("clientSecret", res.data.clientSecret); if (res?.data?.authUrl) { window.location.href = res.data.authUrl; } }) .catch((err) => { console.log(err); }); } useEffect(() => { signInWithGoogle(); }, []); return <div></div>; }
clientId, redirectUrl 는 구글 개발자 콘솔에서 확인
state 생성해서 파라미터 전달및 cookie에저장 (CSRF 공경을 방지하는데 사용됨import crypto from "crypto"; function generateState(length: number = 16): string { return crypto.randomBytes(length).toString("hex"); }
codeVerifier 생성해서 파라미터 전달및 cookie에저장 (PKCE 확장기능에 사용)
function generateCodeVerifier(length: number = 128): string { return crypto .randomBytes(length) .toString("base64") .replace(/\+/g, "-") .replace(/\//g, "_") .replace(/=+$/, ""); }
앞서 생성한 codeVerifier 값을 이용하여 codeChallenge 생성해서 파라미터 전달및 cookie에저장 (PKCE 확장기능에 사용)
function generateCodeChallenge(codeVerifier: string): string { return crypto .createHash("sha256") .update(codeVerifier) .digest("base64") .replace(/\+/g, "-") .replace(/\//g, "_") .replace(/=+$/, ""); }
server에서 전달받은 파라미터를 조합하여 로그인 url 생성
이때 서버에서는 필요한 추가 로직들을 실행 할 수 있음.
clientId같은 정보를 요청시마다 다르게 지정하는것도 가능 (다른서버에 요청)
export async function GET(req: NextRequest) {
const cookies = parse(req.headers.get("cookie") || "");
const result = {
status: { message: "user_cancelled_login", code: 401 },
clientId: process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID,
clientSecret: process.env.NEXT_PUBLIC_GOOGLE_CLIENT_SECRET,
redirectUri: process.env.NEXT_PUBLIC_GOOGLE_REDIRECT_URI,
authUrl: "error",
};
const state = cookies.state ?? "";
const codeChallenge = cookies.codeChallenge ?? "";
const authUrl = generateGoogleOAuthURL({
clientId: result.clientId,
redirectUri: result.redirectUri,
state,
codeChallenge,
});
result.authUrl = authUrl;
const headers = new Headers();
headers.append("Set-Cookie", `clientId=${result.clientId}; Path=/; HttpOnly`);
headers.append(
"Set-Cookie",
`clientSecret=${result.clientSecret}; Path=/; HttpOnly`
);
return new Response(JSON.stringify(result), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
b. 전달받은 파라미터를 generateGoogleAuthURL 함수를 이용해 로그인 url을 생성
function generateGoogleOAuthURL({
clientId,
redirectUri,
state,
codeChallenge,
}: {
clientId: string;
redirectUri: string;
state: string;
codeChallenge: string;
}): string {
const baseUrl = "https://accounts.google.com/o/oauth2/v2/auth";
const params = new URLSearchParams({
client_id: clientId,
scope: "openid email profile https://www.googleapis.com/auth/calendar",
response_type: "code",
redirect_uri: redirectUri,
access_type: "offline",
prompt: "select_account",
state: state,
code_challenge: codeChallenge,
code_challenge_method: "S256",
});
return `${baseUrl}?${params.toString()}`;
}
c. 생성된 url을 1. 로그인 url 요청페이지로 전달하여 구글 로그인 화면으로 redirect
구글 로그인 완료후 code를 받아 token을 받아올 server (api/auth/callback/google)
구글 로그인이 성공하면 자동으로 api/auth/callback/google에 code를 포함한 로그인 url 생성에 사용된 다양한 파라미터가 같이 실려서 전달된다.
export async function GET(req: NextRequest, res: NextResponse) {
const isError = req.url.includes("error=");
if (isError) {
return new Response(null, {
status: 302,
headers: { Location: `/unauthorized/auth_failed` },
});
}
const cookies = parse(req.headers.get("cookie") || "");
//token receiver
try {
const { searchParams } = new URL(req.url);
const code = searchParams.get("code") ?? "";
const codeVerifier = cookies.codeVerifier;
const clientId = cookies.clientId;
const clientSecret = cookies.clientSecret;
const tokens = await getToken({
clientId,
clientSecret,
code,
codeVerifier,
});
if (!tokens) {
return new Response(null, {
status: 302,
headers: { Location: `/unauthorized/auth_failed` },
});
}
let redirectUrl = `/unauthorized/success`;
//로직을 추가하여 redirectUrl을 수정할 수 있음
return new Response(null, {
status: 302,
headers: { Location: redirectUrl },
});
} catch (error: any) {
console.error("**ERROR auth/callback/google", error);
return new Response(null, {
status: 302,
headers: { Location: `/unauthorized/auth_failed` },
});
}
}
b. getToken, 구글 token 서버에 요청하여 accessToken, refreshToken을 가져옴
export const getToken = async ({
clientId,
clientSecret,
code,
codeVerifier,
}: {
clientId?: string;
clientSecret?: string;
code?: string;
codeVerifier?: string;
}) => {
// 파라미터 중 하나라도 falsy 값이면 null 반환
if (!clientId || !clientSecret || !code || !codeVerifier) {
return null;
}
const tokenEndpoint = "https://oauth2.googleapis.com/token";
const params = new URLSearchParams();
params.append("client_id", clientId);
params.append("client_secret", clientSecret);
params.append("code", code);
params.append("grant_type", "authorization_code");
params.append(
"redirect_uri",
process.env.NEXT_PUBLIC_GOOGLE_REDIRECT_URI ?? ""
);
params.append("code_challenge", "");
params.append("code_verifier", codeVerifier);
const tokens = { accessToken: "", refreshToken: "" };
try {
const res = await axios.post(tokenEndpoint, params, {
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
});
console.log("Token successfully retrieved");
tokens.accessToken = res.data.access_token;
tokens.refreshToken = res.data.refresh_token;
} catch (error) {
console.error("Error retrieving token:", error);
return null;
}
if (!tokens.accessToken) {
return null;
}
return tokens;
};
c. 전달 받은 토큰은 서버에서 사용할 수도있고 클라이언트 에 전달할수도있음.
d. 서버에서는 토큰등 결과를 확인하고 프론트의 특정 페이지로 redirect 시킨다.
api/auth/callback/google 요청결과에 따라 redirect되는 페이지로 특정 조건에 맞는 화면을 보여주거나 로직을 실행시키면 완료된다.