SSO(Single Sign On)
https://velog.io/@juwon98/SSO
위 포스트를 작성할 때 구현해둔 SSO 인증 로직을 블로그에 정리하려 했는데, 바쁘게 지내다 보니 잊고 있었다...
보통은 SSO를 직접 구현하기 보다는 google, kakao 등에서 제공하는 인증 서비스를 사용할 것이다.
하지만, MSA(Micro Service Architecture)에서 여러 서비스 간 인증상태를 공유하기 위해 SSO를 사용하는 경우가 많다고 들었고, Next.js 앱을 인증서버로 활용하여 (편의상 next-auth사용) OAuth2.0 방식을 사용한 SSO 인증을 직접 구현하는 방법을 정리해보았다.
import { client } from '@/lib/db';
import crypto from 'crypto';
import { getServerSession } from 'next-auth';
import { NextResponse } from 'next/server';
import { authOptions } from '../../auth/[...nextauth]/route';
// db에 접근할 인스턴스
const db = client();
export async function GET(req: Request) {
const session = await getServerSession(authOptions);
const userId = session?.user.accessToken.employeeId;
const { searchParams } = new URL(req.url);
const redirectUri = searchParams.get('redirect_uri');
const clientId = searchParams.get('client_id');
if (!session) {
// 세션 없으면 로그인 페이지로
return NextResponse.redirect(
`/pages/signin${
clientId && redirectUri
? `?client_id=${clientId}&redirect_uri=${redirectUri}`
: ''
}`,
);
}
if (!redirectUri) {
return NextResponse.json(
{ error: 'redirect_uri가 없습니다.' },
{ status: 400 },
);
}
// 허용된 client_id인지 확인
if (
!clientId ||
!process.env.ALLOWED_CLIENT_IDS?.split(',').includes(clientId)
) {
return NextResponse.json(
{ error: '허용되지 않은 Client' },
{ status: 400 },
);
}
// 인가코드 발급
const code = crypto.randomBytes(32).toString('base64url');
const expiresAt = new Date(Date.now() + 5 * 60 * 1000); // 5분 후 만료
/*
DB, Redis 등에 저장
...
*/
// redirect_uri로 리다이렉트 (인가코드 포함)
return NextResponse.redirect(
`${redirectUri}${redirectUri.includes('?') ? '&' : '?'}code=${code}`,
);
}
import { client } from '@/lib/db';
import { signJwtAccessToken } from '@/lib/jwt';
import { NextResponse } from 'next/server';
// Cross Origin 허용
const headers = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET,POST,PUT,DELETE,OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
};
export async function OPTIONS() {
return new Response(null, {
status: 200,
headers,
});
}
const db = client();
export async function POST(req: Request) {
const { code } = await req.json();
let authCode;
/*
/api/oauth/outhorize에서 저장한 인가코드 조회
(user_id, expires_at 포함)
...
*/
if (
!authCode ||
authCode.used ||
new Date(authCode.expires_at) < new Date()
) {
return NextResponse.json(
{ error: '유효하지 않은 인가코드' },
{ status: 400, headers },
);
}
/*
authCode 사용 완료 처리
...
*/
// access_token 발급
const userQuery = `
-- DB에서 사용자 조회
`;
const user = await db.oneOrNone(userQuery, authCode.user_id);
const accessToken = signJwtAccessToken(user);
const body = JSON.stringify({
access_token: accessToken,
token_type: 'Bearer',
expires_in: 3600,
});
return new Response(body, {
status: 200,
headers,
});
}
(MSA에서는 각 MicroService가 될 수 있음)
프론트엔드에 vite + react를 사용했다고 가정
# 32자리(16바이트) 랜덤 16진수 id 생성
openssl rand -hex 16
터미널에서 위의 명령어를 실행해서 랜덤 16진수를 생성한다.
→ Next.js 앱(인증서버)의 .env 환경변수 파일에 ALLOWED_CLIENT_IDS에 해당 id를 추가한다.
# SSO 이용 허가된 CLIENT들의 ID
ALLOWED_CLIENT_IDS=ab0f4a3ab8997fb2035acd00524cceb6,a20266bd29639bf243dcbb29a4be065f, ...
→ Client 앱의 .env 환경변수 파일에도 해당 id를 추가한다.
# Next.js 앱에 등록한 id와 동일
VITE_CLIENT_ID=ab0f4a3ab8997fb2035acd00524cceb6
// 예시
<button
type="button"
onClick={async () => {
const clientId = import.meta.env.VITE_CLIENT_ID;
const redirectUri = encodeURIComponent('http://localhost:3001/callback');
window.location.href = `http://localhost:3000/pages/signin?client_id=${clientId}&redirect_uri=${redirectUri}`;
}}>
로그인 테스트
</button>
버튼을 클릭하면 (Next.js 앱의)로그인 페이지로 이동한다.
이때, 쿼리스트링으로 redirect_uri를 설정하여 이동한다.
→ 로그인 페이지로 이동 후, id, pwd를 입력하여 로그인 성공 시(그 전에 한 번 로그인 한 상태라면 즉시) redirect_uri로 다시 돌아오게 된다.
redirect_uri를 callback이라는 경로로 설정한 이유가 있다.
인증 성공 시 redirect_uri로 돌아오는데, 쿼리스트링에 ?code=FZMQ98S03emxLtCJg6_n39f7M-MCLP4P6BC9RGBSEcE 와 같이 unique한 문자열이 포함되어 돌아온다.
// /src/routes/callback/index.tsx
export const Route = createFileRoute('/callback/')({
component: RouteComponent,
});
function RouteComponent() {
// 쿼리스트링 추출
const searchParams = getRouteApi(Route.fullPath).useSearch();
const code = searchParams.code;
useEffect(() => {
// 개발 환경의 react StrictMode에서 두 번 실행되는 것을 방지하기 위함
const abortController = new AbortController();
const getToken = async () => {
try {
const response = await fetch('http://localhost:3000/api/oauth/token', {
method: 'POST',
signal: abortController.signal,
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ code }),
});
const data = await response.json();
console.log('data', data);
}
// 에러 처리
catch (error: unknown) {
if (error instanceof Error) {
if (error.name === 'AbortError') return;
console.error('error', error.message);
} else {
console.error('error', error);
}
}
};
getToken();
return () => {
abortController.abort();
};
}, [code]);
return <div>Hello "/callback"!</div>;
그 code를 HTTP Request의 Body에 담아서 다시 http://localhost:3000/api/oauth/token과 같은 경로로 기존 Next 앱의 api를 호출하면 반환값으로 아래와 같은 access_token을 확인할 수 있다.
{
access_token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbXBsb3llZUlkIjoiYWRtaW4iLCJlbXBsb3llZU5hbWUiOiLqtIDrpqzsnpAiLCJjb21wYW55Q29kZSI6IjEwIiwiY29tcGFueU5hbWUiOiIo7KO8KeyVhOyKpO2KuCIsImVtcGxveWVlTnVtYmVyIjoiMTIwMCIsInJvbGUiOiJUT1BfQURNSU4iLCJpYXQiOjE3NDg1Njk0ODMsImV4cCI6MTc0ODU3MzA4M30.1h4xQQKFRUL0DKmrXL_xDfbQ5Rd8bIVcRQjFlyeVmtk",
expires_in: 3600,
token_type: "Bearer"
}