[42Seoul] ft_transcendence - 03
이제 코드에 관한 이야기를 해볼까 한다.(굉장히 오랜만이다.) 과제에서는 모든 사용자는 42인트라넷의 OAuth를 통해서 로그인해야 한다고 명시되어 있다. 우리는 차후 배포 해볼 계획도 가지고 있어서 구글 소셜 로그인도 함께 구현을 했다.
OAuth
OAuth는 "Open Authorization"의 약자로, 사용자가 제3자 애플리케이션에 자신의 정보에 대한 일정한 접근 권한을 부여할 수 있게 해주는 표준 프로토콜이다. 이를 통해 사용자는 자신의 하나의 계정 정보로 다양한 서비스와 애플리케이션에 로그인하고 특정한 정보나 기능에 접근할 수 있게 된다.
OAuth 기본 원리
- 리소스 소유자(Resource Owner): 데이터의 실제 소유자, 여기서는 42 카뎃이 된다.
- 클라이언트(Client): 사용자의 정보에 접근하려는 제3자 애플리케이션. 여기서는 트센의 웹 페이지가 된다.
- 리소스 서버(Resource Server): 사용자 데이터를 호스팅하는 서버. 여기서는 42인트라의 서버가 된다.
- 인증 서버(Authorization Server): 사용자의 신원을 확인하고 애플리케이션에 특정 권한을 부여하는 토큰을 제공하는 서버. 여기서는 트센의 서버가 된다.
OAuth 인증 과정
- 사용자 인증 및 권한 부여: 사용자는 제3자 애플리케이션에서 "로그인" 버튼을 클릭하게 되고, 이때 OAuth 제공자의 로그인 페이지로 리다이렉트된다.
- 코드 부여: 사용자가 성공적으로 로그인하고 권한을 부여하면, 제3자 애플리케이션은 "인증 코드"를 받게 된다.
- 토큰 교환: 제3자 애플리케이션은 이 코드를 다시 인증 서버로 보내고, 그 대신에 "액세스 토큰"을 받는다.
- 리소스 접근: 이제 애플리케이션은 받은 액세스 토큰을 사용하여 사용자의 정보나 기능에 접근할 수 있다.
OAuth 장점
- 보안성: 사용자는 비밀번호를 직접 공유하지 않아도 제3자 애플리케이션에 특정 권한을 부여할 수 있다.
- 간편성: 사용자는 하나의 계정으로 여러 서비스에 로그인하거나 데이터를 공유할 수 있다.
- 제어: 사용자는 제3자 애플리케이션에 어떤 권한을 부여할지 선택할 수 있습니다. 또한, 언제든지 이 권한을 취소할 수 있다.
주의사항
- OAuth는 인증뿐만 아니라 권한 부여에 중점을 둡니다. 이를 위해 제3자 애플리케이션에 접근 권한을 부여하는 "액세스 토큰"을 사용한다.
- OAuth 2.0은 현재 가장 널리 사용되는 버전입니다. 이전 버전인 OAuth 1.0a는 복잡한 서명 과정 때문에 덜 인기가 있다.
- 보안에 민감한 애플리케이션에서는 OAuth 2.0에 추가적인 보안 조치인 "OpenID Connect"나 "Proof Key for Code Exchange (PKCE)" 같은 것을 사용해야 할 수도 있다.
사용자 인증 및 권한 구성
사용자 인증을 위해서는 OAuth 제공자의 인증을 받아야 한다. 이것을 URL을 통해 가능하게 할 수 있는데, OAuth URL을 구성하기 위해 필요한 주요 요소들은 다음과 같다.
- Base URL (엔드포인트): 인증 서버의 주소로, 사용자를 리다이렉트할 때 사용된다. 일반적으로 OAuth 제공자별로 문서화되어 있으며, 예로는 Google의 OAuth 2.0 엔드포인트는
https://accounts.google.com/o/oauth2/v2/auth
와 같은 형식을 가진다. - Client ID: 애플리케이션을 OAuth 제공자에 등록할 때 받게 되는 고유한 식별자이다. 이를 통해 인증 서버는 요청이 어느 애플리케이션에서 오는지 파악한다.
- Redirect URI: 사용자가 성공적으로 인증하거나 권한을 부여한 후에 리다이렉트될 애플리케이션의 URL이다. 이 주소는 애플리케이션 등록 시에 OAuth 제공자에게 알려줘야 하며, 실제 요청 시 제공하는 주소와 일치해야 한다.
- Response Type: 애플리케이션이 받길 원하는 인증 응답의 타입이다. 가장 일반적인 값은
code
로, 이는 인증 코드를 받기 위함이다. 그 후에 이 코드를 액세스 토큰으로 교환하는 요청을 따로 보내게 된다. - Scope: 애플리케이션에 필요한 권한 범위를 나타낸다. 이는 사용자의 어떠한 정보나 기능에 접근할 수 있는지를 정의한다. 예를 들면, 사용자의 이메일이나 프로필 정보, 친구 목록 등의 접근을 요청할 수 있다.
- State (선택 사항): 애플리케이션에서 생성한 임의의 문자열로, CSRF 공격을 방지하는데 사용된다. 인증 서버는 이 값을 변경하지 않고 그대로 반환해준다. 애플리케이션이 이 값을 검증하여 요청이 변조되지 않았음을 확인할 수 있다.
OAuth URL을 구성하는 것은 위 요소들을 적절히 조합해 사용자를 인증 서버로 안전하게 리다이렉트하는 것에 목적이 있다.
const ft_oauth = {
base_url: "https://api.intra.42.fr/oauth/authorize",
client_id: process.env.VITE_FT_OAUTH_CLIENT_ID as string,
redirect_uri: process.env.VITE_FT_OAUTH_REDIRECT_URI as string,
};
const google_oauth = {
base_url: "https://accounts.google.com/o/oauth2/v2/auth",
client_id: process.env.VITE_GOOGLE_OAUTH_CLIENT_ID as string,
redirect_uri: process.env.VITE_GOOGLE_OAUTH_REDIRECT_URI as string,
};
이것은 각각 42 인트라와 구글의 oauth 인증정보를 담은 객체이다. 우리는 이 객체의 정보를 이용해서 로그인 버튼에 들어갈 url을 만들어주었다.
const oauth_forty_two = `${ft_oauth.base_url}?client_id=${encodeURIComponent(
ft_oauth.client_id,
)}&redirect_uri=${encodeURIComponent(
ft_oauth.redirect_uri,
)}&response_type=code`;
구글과 42인트라의 url 구성은 비슷하다.
ft_oauth.base_url
: 42 인트라의 OAuth 인증 엔드포인트이다. 사용자는 이 URL로 리다이렉트되어 42 인증을 진행하게 된다.client_id=${encodeURIComponent(ft_oauth.client_id)}
:client_id
는 OAuth 어플리케이션의 ID를 나타낸다. 이 값을 사용하여 OAuth 제공자(여기서는 42 인트라)가 어떤 앱에서 인증 요청이 왔는지를 파악한다.encodeURIComponent
는 특수 문자들을 안전하게 URL에 포함시키기 위해 사용된다.redirect_uri=${encodeURIComponent(ft_oauth.redirect_uri)}
: 인증이 성공하면 사용자는 이redirect_uri
로 리다이렉트된다. 인증 서버는 이 주소로 authorization code나 token을 전달한다.response_type=code
: 이는 인증 서버에게 authorization code 플로우를 사용하고 싶다는 것을 나타낸다. 이 code는 나중에 액세스 토큰으로 교환된다.
위의 oauth url을 가지고 로그인 버튼을 만들어 주었다.
인증 과정
OAuth를 통해 제공자 페이지에서 코드를 제공 받았다면, 우리의 웹 서버에서도 그 코드에 대한 검증이 이루어저야 한다. 클라이언트는 OAuth 제공자에서 코드를 제공 받았다면 코드를 백엔드 서버로 전송하게 된다.
백엔드 서버에서는 이 코드를 검증하고, 코드가 올바른 경우 인증 토큰을 발급해주는데, 이 토큰은 JWT로 되어있다.
JWT (JSON Web Token)
JWT는 웹 표준 (RFC 7519)으로 정의된, 간단하면서도 강력한 방법으로 정보를 안전하게 전송하기 위한 작은 정보 조각이다. JWT는 두 개체 사이에서 정보를 안전하게 전송하는 방법을 제공한다. JWT는 많은 인증 및 인가 시스템에서 주로 사용되며, 특히 단일 페이지 어플리케이션(SPA)에서 서버 API 호출에 대한 인증 및 인가에 사용된다.
JWT의 구조는 크게 세 부분으로 나눌 수 있으며 각 속성들은 '.'
으로 구분되어 있다.
- Header: JWT의 타입 (일반적으로 "JWT") 및 사용되는 서명 알고리즘 (예: HMAC SHA256 또는 RSA)을 포함한다.
- Payload: 토큰에 포함될 클레임(주장)을 포함한다. 클레임은 토큰 발행자, 만료 시간, 사용자 이름 등과 같은 정보를 포함할 수 있다.
- Signature: 서명은 헤더와 페이로드를 암호화하여 생성된다. 서명은 토큰의 무결성을 확인하는 데 사용된다.
위의 속성들은 일반적으로 다음과 같은 형태를 가지게 된다.
header.payload.signature
이 JWT 토큰을 클라이언트로 반환해서 인증된 사용자라는 것을 향후에도 알 수 있게 된다.
@Post('/login')
async login(@Response() res, @Body() loginDto: LoginDto) {
const { code, type } = loginDto;
let OwnerId;
if (type === '42') {
OwnerId = await this.authService.getResourceOwner42Id(code);
OwnerId = '42-' + OwnerId;
} else if (type === 'google') {
OwnerId = await this.authService.getResourceOwnerGoogleId(code);
OwnerId = 'G-' + OwnerId;
} else {
throw new HttpException(
'유효하지 않은 로그인 유형입니다.',
HttpStatus.BAD_REQUEST,
);
}
if (OwnerId === '-1') {
throw new HttpException(
"Can't get resourceOwner ID",
HttpStatus.BAD_REQUEST,
);
} else {
if ((await this.usersService.checkUser(OwnerId)) == false) {
await this.usersService.createUser(OwnerId);
}
const user = await this.usersService.getUserById(OwnerId);
const payload = { id: OwnerId, sub: OwnerId };
if (
user.status === UserStatusType.OFFLINE ||
user.status === UserStatusType.SIGNUP
) {
const jwt = await this.authService.sign(payload);
res.setHeader('Authorization', 'Bearer ' + jwt);
res.cookie('jwt', jwt, {
maxAge: 3600000,
});
this.rootGateway.refreshUsersList();
return res.send(user);
} else {
throw new HttpException('User is already online', HttpStatus.CONFLICT);
}
}
}
우리 팀의 백엔드 서버에서의 로그인 과정이다. 순서대로 살펴보면 다음과 같다.
- 42 OAuth 혹은 구글 OAuth에 따라 id를 따로 받아준다.
- 이 함수에서 구글 혹은 42의 토큰 엔드포인트에 POST 요청을 통해 코드를 인증받게 된다.
- 만약 POST 요청의 응답 코드가 200이라면 성공적으로 엑세스 토큰을 발급 받았다는 의미이다.
- 엑세스 토큰을 사용해 사용자 정보를 가지고 온다.
- 사용자 정보 요청도 성공적으로 응답되었다면 다음 단계로 넘어간다.
- 앞선 과정에서 올바른 응답을 받지 못했을 경우 로그인은 실패하게 된다.
- 발급받은 id를 통해 데이터베이스의 유저 정보를 검색할 수 있다.
- 유저 정보가 존재하지 않는 경우 새로운 유저를 만들어준다.
- 기존 유저와 새로운 유저 모두 새로운 토큰을 발급해준다.
- 만약 기존 유저의 경우, 온라인 상태일 때 중복 로그인을 허용하지 않기 위해 토큰 발급을 하지 않는다.
- 모든 과정을 성공적으로 수행했을 경우, 유저 정보를 클라이언트에 반환한다.