본문 바로가기

카테고리 없음

[OAuth In Action] Node.js로 OAuth 의 3주체를 구현해보자 (보호된 리소스, 클라이언트, 인증 서버)

안녕하세요 브로콜리입니다.

 

오늘은 OAuth2 In Action 의 ch3-ch5를 실습하며 OAuth2의 인증과정과 구현원리를 학습한 내용을 정리해보려 합니다.

(모든 기반 코드는 OAuth2 In Action 실습 코드를 활용하였습니다.)

 

OAuth2란?

시스템의 어떤 구성요소가 다른 시스템의 어떤 구성요소에 대한 접근 권한을 얻을 수 있게 해주는 것입니다.

즉, 특정 리소스의 접근 권한을 다른 누군가에게 위임하는 과정을 의미합니다.


1. OAuth의 4요소 : 리소스 소유자, 보호된 리소스,  클라이언트,  인가서버

 

리소스 소유자

: API에 대한 접근 권한을 가지고 있으며 그것을 위임할 수 있는 사람입니다.

: 주로 자신의 정보를 프로그램에게 넘기는 유저로 대변됩니다.

 

보호된 리소스

: 리소스 소유자가 접근하는 구성요소입니다.

: 주로 웹 API의 형태를 띕니다.

: 유저가 조회가능한 개인정보(이메일, 프로필 사진 등등)이 대표적인 예시입니다.

 

클라이언트

: 웹에서의 '클라이언트'와는 다른 의미를 지닙니다.

: 리소스 소유자로부터 권한을 위임받아 보호된 리소스에 접근하고자 하는 주체입니다.

: 리소스 소유자가 이용하는 애플리케이션으로 대변됩니다.

 

인가 서버

: 리소스 소유자가 클라이언트에게 접근 권한을 위임하도록 요청합니다.

: 리소스의 접근 가능한 엑세스 토큰을 클라이언트에게 발급합니다.

 


2. OAuth 흐름 정리

 

OAuth의 흐름은 크게 3가지로 나뉘어져 있습니다.

1. 리소스 소유자가 클라이언트에게 접근권한을 위임하는 과정
2. 클라이언트가 인가 서버에게 위임사실을 확인하고 보호된 리소스에 접근 가능한 엑세스 토큰을 발급받는 과정
3. 클라이언트가 엑세스 토큰을 활용해 보호된 리소스에 접근하는 과정

 

그 중 인가 코드를 활용한 OAuth의 대표적인 흐름은 다음과 같습니다.

 

step1. 리소스 소유자가 클라이언트에게 접근권한을 위임하는 과정

1.애플리케이션이 인가서버의 인가 엔드포인트로 리소스 소유자를 리다이렉트 시킵니다.
2. 인가 엔드 포인트에서 리소스 소유자는 자신이 접근 가능한 보호된 리소스의 접근 궈한을 클라이언트에게 위임합니다.
3. 인가서버는 권한 위임을 증명하는 인가코드와 함께 다시 클라이언트로 리다이렉트 시킵니다.

 

 

step2. 클라이언트가 인가 서버에게 위임사실을 확인하고 보호된 리소스에 접근 가능한 엑세스 토큰을 발급받는 과정

1. 클라이언트는 인가서버에게 인가코드와 자격증명 정보를 전달합니다.
    - 인가코드 : 리소스 소유자로부터 접근권한을 위임받았음을 증명하는 코드
    - 자격증명정보 : 권한을 위임받은 주체인 클라이언트에 대한 증빙 정보

2. 인가서버는 리소스 접근이 가능한 엑세스 토큰을 클라이언트에게 전달합니다.

 

 

step3. 클라이언트가 엑세스 토큰을 활용해 보호된 리소스에 접근합니다.

1. 엑세스 토큰을 활용해 보호된 리소스에 접근합니다.
2. 보호된 리소스에서 리소스 정보를 제공합니다.

 

 

이 세가지 단계를 총정리해보자면 다음과 같습니다.

 


3. OAuth 클라이언트 구현하기

 

3.1 인가서버에 OAuth 클라이언트 등록

 

클라이언트가 인가서버와 대화하기 위해서는 서로가 상대방에 대한 몇가지 정보를 알아야 합니다.

 

3.1.1 인가서버는 클라이언트 아이디와 비밀번호로 클라이언트를 식별가능해야 한다

먼저 인가서버는 클라이언트 식별자를 통해 각 클라이언트를 구분하며, 클라이언트 비밀번호를 통해 자격 정보를 증명받습니다.

실습코드 ch3-ex-1의 client.js에 인가 서버로부터 발급받은 클라이언트 식별자(client_id)와 클라이언트 비밀번호(client_secret)를 등록해주겠습니다.

var client = {
    "client_id": "oauth-client-1",
    "client_secret": "oauth-client-secret-1",
    "redirect_uris": ["http://localhost:9000/callback"]
};

 

3.1.2 클라이언트는 인가서버의 인가 엔드포인트, 토큰 엔드 포인트를 알아야 한다.

 

클라이언트는

인가코드를 발급받기 위해 리소스 소유자를 리다이렉트 시킬 인가포인트와

엑세스 토큰 발급을 위한 토큰 엔드 포인트를 알고 있어야 합니다.

var authServer = {
    authorizationEndpoint: 'http://localhost:9001/authorize',
    tokenEndpoint: 'http://localhost:9001/token'
};

 

3.2 인가 코드 그랜트 타입을 통해 토큰 얻기 

 

큰 흐름은 다음과 같습니다.

1) 리소스 소유자를 인가 서버의 인가 엔드 포인트로 리다이렉트
2) 소유자가 인가하면 redirect_url로 인가코드를 전달
3) 인가코드 + 클라이언트 자격증명 정보로 엑세스 토큰 발급

 

3.2.1 리소스 소유자를 인가 서버의 인가 엔드 포인트로 리다이렉트

먼저 리소스 소유자를 인가서버의 인가 엔드 포인트인 'http://localhost:9001/authorize'로 리다이렉트 해보겠습니다.

/인가 서버의 엔드 포인트로 인가 요청 보내기
app.get('/authorize', function (req, res) {

    var authorizeUrl = buildUrl(authServer.authorizationEndpoint, {
        response_type: 'code',
        client_id: client.client_id,
        redirect_uri: client.redirect_uris[0],
    });

    console.log("redirect", authorizeUrl);
    res.redirect(authorizeUrl);

});

 

/authroize로 접근한다면 인가서버의 인가 엔드 포인트로 클라이언트의 식별자와 리다이렉트 url을 함께 실어보냅니다.

 

실제 구현동작을 본다면 토큰을 얻기전 클라이언트의 초기 상태가 다음과 같다면

 

Get OAuth Token을 눌러 인증요청과 동시에 인가서버의 인가 엔드 포인트로 리다이렉트 되는 모습을 볼 수 있습니다.

-> locahost:9001/authorize로 리다이렉트 된 모습

 


3.2.2. 인가 요청에 대한 응답 처리

만약 리소스 소유자가 권한을 위임하여 인가하였다면 인가서버는 인가코드와 함께 client가 요청에 함께 실어보낸 리다이렉트 uri인 'http://localhost:9000/callback'으로 리다이렉트 해줍니다.

 

이후, 클라이언트는 인가코드를 넣어 토큰 엔드 포인트로 직접 http post를 전송해야 합니다.

var form_data = qs.stringify({
        grant_type: 'authorization_code',
        code: code,
        redirect_uri: client.redirect_uris[0] // 인가서버에서 인가 최초 요청과 redirect_uri을 검증함
    });

 

그런데 토큰을 발급할 때는 더 이상 리다이렉트가 필요없음에도 redirect_url을 왜 포함해 보낼까요?

OAuth 스펙에 따르면 인가 요청에 redirect_url이 포함되어 있었다면 토큰을 요청할 때도 그것과 동일한 url을 함께 전달해야 합니다. 그렇게 함으로써 공격자가 침해된 리다이렉트 url과 세션에 인가코드를 삽입하는 것을 방지할 수 있기 때문입니다.

 

또한, 클라이언트 자격증명을 위해 사용자 이름과 비밀번호를 콜론으로 연결해 Base64로 인코딩한 문자열을 전송합니다.

//클라이언트 식별 정보를 header에 같이 넣어 보냄
    var headers = {
        "Content-Type": 'application/x-www-form-urlencoded',
        'Authorization': 'Basic ' + encodeClientCredentials(client.client_id, client.client_secret)
    };

 

그런 다음 다음과 같이 인가서버의 토큰 엔드 포인트로 POST 요청을 보냅니다.

var tokRes = request('POST', authServer.tokenEndpoint, {
        body: form_data, //인가 코드
        headers: headers //클라이언트 식별 정보
    });

 

요청이 성공하면 인가서버는 엑세스 토큰과 같이 몇 가지 값을 함께 포함하는 JSON 객체를 반환하게 됩니다.

{
   "access_token": "엑세스 토큰...",
   "token_type": "Bearer"
}

 


3.2.3. 크로스 사이트 공격 방지를 위한 state 파라미터 추가

현재 http://localhost://9000/callback을 방문할 때마다 입력값을 가져와 누군가 다시 인가서버에 전달하려할 수 있습니다. 즉, 자신이 한 인가 요청이 아님에도 불구하고 계속해서 토큰 발급을 시도할 수 있게 됩니다.

 

따라서 클라이언트는 자신의 인가요청 정보를 기억해둠으로서(state)

자신이 한 인가요청에 대해서만 토큰 발급을 시도하도록 할 수 있습니다.

 

방법은 간단합니다. 인가 요청 시 state를 포함하여 인가 코드를 발급받고

tate = randomstring.generate(); //인가 요청 고유값
    var authorizeUrl = buildUrl(authServer.authorizationEndpoint, {
        response_type: 'code',
        client_id: client.client_id,
        redirect_uri: client.redirect_uris[0],
        state: state
    });

 

토큰 발급 시도 시, 저장된 인가 요청의 state와 인가요청으로 부터 리다이렉트로 전달된 state가 같은지 검증한 후, 토큰 발급을 시도하면 됩니다.

//state 값이 같은지 검증 -> 인가 요청을 보낸 값과 같은지 검증
    if (req.query.state != state) {
        res.render('error', {error: 'State value did not match'});
        return;
    }

 


3.3 보호된 리소스에 접근하기 위한 토큰 사용

 

이제 발급받은 엑세스 토큰을 사용해 보호된 리소스에 접근하면 됩니다. 크게 3가지 방법이 있습니다.

1. 엑세스 토큰을 Authorization 헤더에 넣기(권장)
2. 엑세스 토큰을 body에 넣기
3. 엑세스 토큰을 쿼리 파라미터로 전달하기

 

OAuth 스펙에서는 엑세스 토큰을 Authorization header에 넣는 것을 권장하고 있습니다. 다음의 단점이 있기 때문입니다.

- body : POST 스펙에만 토큰을 전달가능함

- 쿼리 파라미터 : 서버 로그에 엑세스 토큰 값이 유출가능

 

 

그럼 이번엔 Get Protected Resource 버튼을 눌러 보호된 리소스를 가져오는 방법을 생각해보겠습니다.

 

먼저 엑세스 토큰이 없으면 에러를 반환시킵니다.

//액세스 토큰이 없다면 오류 페이지 렌더링
    if (!access_token) {
        res.render('error', {error: 'Missing access token.'});
        return;
    }

 

실제로 엑세스 토큰이 없는 상태로 보호된 리소스에 접근하려고 하면 에러가 표시되는 모습을 볼 수 있습니다.

 

만약 엑세스 토큰이 있다면 Authorization 헤더에 엑세스 토큰을 넣고, 보호된 리소스 url로 리소스를 요청해보겠습니다.

   var protectedResource = 'http://localhost:9002/resource';

   //액세스 토큰을 헤더에 담음
    var headers = {
        'Authorization': 'Bearer ' + access_token
    };

    //보호된 리소스를 요청함
    var resourceRes = request('POST', protectedResource, {headers: headers});

 

만약 2XX 대의 정상반환이면 data를 담아 렌더링하고 아니라면 에러를 반환합니다.

    // 정상 응답일 때 -> data view 렌더링
    // 정상이 아닐 때 -> 에러 렌더링
    if(resourceRes.statusCode >= 200 && resourceRes.statusCode < 300){
        var body = JSON.parse(resourceRes.getBody());
        res.render('data', {resource :body});
        return;
    }else{
        res.render('error', {error: 'Server returned response code; ' + resourceRes.statusCode});
        return;
    }

 

이제 토큰을 발급받고 보호된 리소스에 잘 접근가능한 모습을 볼 수 있습니다.

 


3.4 리프레시 토큰을 활용한 엑세스 토큰 갱신

 

OAuth2.0은 보호된 리소스에 접근 가능한 엑세스 토큰 만료시 리프레시 토큰을 활용해 사용자 개입 없이도 새로운 엑세스 토큰을 갱신할 수 있는 방법을 제공합니다.  

 

인가서버의 토큰 엔드 포인트로 엑세스 토큰을 요청하면 엑세스 토큰과 리프레시 토큰을 함께 발급해줍니다.

{
   "access_token": "엑세스 토큰...",
   "refresh_token": "리프레시 토큰.."
   "token_type": "Bearer"
}

 

엑세스 토큰이 만려되면 토큰 엔드 포인트로 토큰 갱신 요청을 보냅니다.

var refreshAccessToken = function(req, res) {

    //grant_type 을 리프레시 토큰을 설정함으로써 인가서버에 토큰 갱신요청임을 알림
	var form_data = qs.stringify({
		grant_type : "refresh_token",
		refresh_token : refresh_token
	});

	var headers = {
		"Content-Type": "application/x-www-form-urlencoded",
		"Authorization" : "Basic " + encodeClientCredentials(client.client_id, client.client_secret)
	};

	var tokenRes = request("POST", authServer.tokenEndpoint, {
		body: form_data,
		headers : headers
	});
}

 

갱신 받은 엑세스 토큰으로 다시 접근을 해보면 보호된 리소스에 잘 접근되는 모습을 볼 수 있습니다.

 

실제로도 인가서버 내역을 보면 리프레시 토큰을 발급했다는 것과 

보호된 리소스에 접근하기 위해 새로 발급된 엑세스 토큰값을 확인할 수 있습니다.


4. 리소스 서버 구현하기

 

리소스 서버는 클라이언트가 보낸 요청에서 OAuth 토큰을 추출해 그것을 검증해야 합니다. 그에 따라 개념적으로 보호된 리소스와 인가서버는 분리된 개념이지만, 실무에서는 같은 곳에 구현되는 경우가 많습니다.

 

4.1 Http 요청에서 OAuth 토큰 파싱하기

 

OAuth Bearer Token Usage 스펙에서는 보호된 리소스에서 토큰을 보내기 위한 3가지 방법을 정의하고 있습니다.

1. 엑세스 토큰을 Authorization 헤더에 넣기(권장)
2. 엑세스 토큰을 body에 넣기 -> 입력 방식을 POST로 제한할수 있음
3. 엑세스 토큰을 쿼리 파라미터로 전달하기 -> 부주의하게 로깅되거나 헤더로 노출확률이 높음

 

따라서 이 3가지 방법을 모두 지원하는 엑세스 토큰 파싱로직을 만들면 다음과 같습니다.

var getAccessToken = function(req, res, next) {
    var inToken = null;
    var auth = req.headers['authorization']; //인가 헤더

    //1. 인가 헤더에 bearer 토큰
    if(auth && auth.toLowerCase().indexOf('bearer') == 0) {
        inToken = auth.slice("bearer ".length);

    }else if(req.body && req.body.access_token){
        //2. Post req body에 담기
        inToken = req.body.access_token;
    }else if(req.query && req.query.access_token){
        //3. 쿼리 파라미터에 담기
        inToken = req.query.access_token;
    }
}

 

 

4.2 엑세스 토큰의 유효성 확인

 

이제 리소스 서버는 토큰을 파싱했습니다. 그럼 파싱된 토큰이 실제로 유효한 토큰인지를 확인해야겠지요. 예제 애플리케이션에서는 인가서버가 토큰을 저장하기 위해 사용하는 데이터베이스에 리소스 서버가 접근이 가능합니다. 따라서, 리소스 서버에 들어온 토큰이 db에 있는지 검증하는 과정을 통해 유효성 검사를 실시해보려 합니다.

nosql.one(function(token) {
        // 하나씩 입력 토큰이 저장된 액세스 토큰과 동일한지 비교
        if (token.access_token == inToken) {
            return token;
        }
    }, function(err, token){
        //모두 끝난 후 찾은 / 찾지 못한 token 값을 기반으로 console 출력
        // 요청 객체의 access_token에 할당 한 이후에 넘김
        if(token) {
            console.log("We found a matching token: %s", inToken);
        } else {
            console.log("No matching token was found.");
        }
        req.access_token = token;
        next();
        return;
    });
}

 

POST "/resource"로 요청을 받았을 때 먼저 getAccessToken을 통해 파싱 및 검증을 한 이후 보호된 리소스에 접근하게 한 결과

app.post("/resource", getAccessToken, cors(), function(req, res) {

    if(req.access_token){
        res.json(resource);
    }else{
        res.status(401).end();
    }
});

 

적절한 토큰을 발급받았을 때는 보호된 리소스에 접근이 되나

 

리소스를 발급받지 않았을 때는 401이 뜨는 것을 볼 수 있었습니다.

4.3 토큰에 기반한 컨텐츠 제공

 

그러나 대부분의 API는 접근 권한에 따라 전달받는 리소스가 다르도록 설계됩니다. 

 

예를 들어 

A : 나는 클라이언트에게 사과만 보여줄래
B : 나는 클라이언트에게 사과 뿐만 아니라 바나나도 보여줄래

라고 자신이 위임한 권한의 scope를 정한다면 각 scope에 맞는 토큰을 발급하고, 리소스 서버는 검증을 통해 허용된 요청인지를 구별하는 것입니다.

 

3가지 예시를 통해 OAuth 토큰을 활용해 리소스 서버가 API를 열어주는 방식에 대해 알아보겠습니다.

 

4.3.1 권한 범위에 따른 작업

 

먼저 클라이언트가 갖고 있는 권한 범위에 따라 수행되는 기능을 나누어보겠습니다. 작업은 크게 3가지 입니다.

1) 현재 설정된 단어와 시간 읽어오기
2) 사전에 새로운 단어 추가하기
3) 마지막 단어 삭제하기

 

 

이 3가지 권한을 확인하기 위해서 리소스 서버는 토큰 객체에서 scope 멤버의 값을 확인해야 합니다.

 

예를 들어 GET /words 요청에는 'read'라는 scope가 있는지 확인하는 것입니다.

app.get('/words', getAccessToken, requireAccessToken, function(req, res) {
	if(__.contains(req.access_token.scope, 'read')) {
		res.json({words: savedWords.join(' '), timestamp: Date.now()});
	}else{
		res.set("WWW-Authenticate", 'Bearer realm=localhost:9002, error ="insufficient_scope", scope="read"');
		res.status(403).end();
	}
});

 

같은 방식으로 post에서는 scope에 'write'가 있는지, delete 에서는 'delete'가 있는지 검증합니다.

app.post('/words', getAccessToken, requireAccessToken, function(req, res) {
	if(__.contains(req.access_token.scope, "write")){
	if (req.body.word) {
		savedWords.push(req.body.word);
		res.status(201).end();
	}
	}else{
		res.set("WWW-Authenticate", 'Bearer realm=localhost:9002, error ="insufficient_scope", scope="write"');
		res.status(403).end();
	}
});

app.delete('/words', getAccessToken, requireAccessToken, function(req, res) {
	if (__.contains(req.access_token.scope, "delete")) {
	savedWords.pop();
	res.status(204).end();
	}else{
		res.set("WWW-Authenticate", 'Bearer realm=localhost:9002, error ="insufficient_scope", scope="delete"');
		res.status(403).end();
	}
});

 

4.3.2 권한 범위에 따른 데이터 반환

 

이번엔 토큰이 어떤 권한 범위를 갖느냐에 따라 동일한 핸들러에서 다른 정보를 반환하는 경우를 보겠습니다.

 

예를 들어 권한 fruite, veggies, meats 가 있을 때, 어떤 scope를 가지느냐에 따라 반환되는 데이터가 다른 경우를 보겠습니다.

 

 

이 경우 리소스 서버는 토큰의 요청 범위에 따라 포함할 데이터를 유동적으로 변화시킬 수 있습니다.

app.get('/produce', getAccessToken, requireAccessToken, function (req, res) {
    var produce = {
        fruit: [],
        veggies: [],
        meats: []
    };
    var tokenScope = req.access_token.scope;

    //토큰 권한 범위에 fruit를 가지고 있을 때
    if (__.contains(tokenScope, "fruit")) {
        produce.fruit = ['apple', 'banana', 'kiwi'];
    }

    //토큰 권한 범위에 veggies를 가지고 있을 때
    if (__.contains(tokenScope, "veggies")) {
        produce.veggies = ['lettuce', 'onion', 'potato'];
    }

    //토큰 권한범위에 meat를 가지고 있을 때
    if(__.contains(tokenScope, "meats")){
        produce.meats = ['bacon', 'steak', 'chicken breast']
    }
    res.json(produce);
});

 

예를 들어 fruit만 체킹한 경우에는 다음과 같은 리소스가 반환됩니다.

 

반대로 fruit과 veggies를 모두 체킹한 경우

 

분명 같은 핸들러에 대한 요청임에도 리소스 서버에서 다른 데이터를 반환해주는 모습을 볼 수 있습니다.

 

전반적으로 OAuth는 권한 부여 결정 프로세스 보다 토큰과 권한 범우를 통해 인가 정보를 전달하는 기반을 마련해줍니다. 그 과정에서 토큰 안에 포함된 정보를 활용해 응답을 규정할 수 있습니다. 즉, 리소스 서버는 항상 엑세스 토큰이 무엇을 의미하는지에 대한 최종 결정권을 가집니다. 엑세스 토큰의 의미를 규정하는 것, 그것이 리소스 서버의 가장 큰 책임입니다.


5. 인가서버 구현하기

 

이제 마지막으로 인가 서버를 구현해보겠습니다.

 

OAuth에서 인가서버는 1) 클라이언트를 관리하고 2) 핵심적인 위임 작업을 수행하며 3) 클라이언트에게 토큰을 발급합니다.

OAuth 스펙에서는 클라이언트나 보호된 리소스에 대한 복잡한 내용들을 최대한 인가 서버 쪽으로 포함되게 했는데요.

 

하나씩 인가서버의 역할을 천천히 구현해 나악보겠습니다.

 

5.1 OAuth 클라이언트 등록 관리

 

먼저 각 클라이언트에게 식별자를 할당해야 합니다. 여기서는 client의 정보를 정적으로 등록하여 관리해보겠습니다.

내용으로는 클라이언트의 자격증명 정보인 id, secret과 redirect_url 등을 포함합니다.

 

여기서는 배열로 관리했으나 실제로는 DB에 저장되어있습니다.

// client information - 클라이언트 정보 내역
var clients = [
	{
		"client_id": "oauth-client-1",
		"client_secret": "oauth-client-secret-1",
		"redirect_uris": ["http://localhost:9000/callback"],
		"scope": "foo bar" //접근 권한 정보
	}
];

 

이를 기반으로 clients 배열에서 식별자인 client_id가 일치하는 클라이언트를 찾는 함수도 생성해보겠습니다.

//클라이언트 아이디를 기반으로 클라이언트를 찾는 구조
var getClient = function(clientId) {
    return __.find(clients, function(client) { return client.client_id == clientId; });
};

 

5.2 클라이언트 인가

 

이제 인가 서버는 2개의 엔드포인트를 가져야 합니다.

1) 인가 엔드 포인트  : 인가 코드를 발급하는 url

2) 토큰 엔드 포인트 : 엑세스 토큰을 발급하는 url 

 

5.2.1 인가 엔드 포인트

 

인가 엔드 포인트는 프론트 채널 엔드 포인트로 이곳에서는 GET /authorizer 로 설정하였습니다.

인가 엔드 포인트의 스텝은 다음과 같습니다.

1) 인가를 요청한 클라이언트가 유효한지 판단한다(client_id 검증)
2) 클라이언트의 요청이 적법한 요청인지 확인한다(redirect_url이 등록한 redirect_url과 동일한지 검증)
3) 사용자에게 권한을 위임할 것인지 묻는 페이지를 보여줌

 

//인가 엔드 포인트 == 프론트 채널
app.get("/authorize", function(req, res){

	//1. 클라이언트 확인
	var client = getClient(req.query.client_id);

	if(!client) {
		res.render('error', {error: 'Unknown client'});
		return;
	}else if(!__.contains(client.redirect_uris, req.query.redirect_uri)) {
        // 2. 적법한 요청인지 redirect_url로 확인
		console.log("Mismatched redirect URI, expected %s, got %s", client.redirect_uris, req.query.redirect_uri);
		res.render('error', {error:'Invalid redirect URI'});
		return;
	}else {
		//권한 위임 이후 요청값을 다시 사용가능해야 함
		var reqid = randomstring.generate(8);
		requests[reqid] = req.query;
        
        //3. 승인 페이지 보여주기
		res.render('approve',{client:client, reqid:reqid, scope : rscope});
	}
});

 

이제 3가지 주체를 모두 실행하고 client가 get token을 누른다면 승인 페이지로 이동되는 모습을 볼 수 있습니다.

 

 

5.2.2 클라이언트의 인가

 

만약 사용자가 권한을 위임했을 때, 인가서버는 다음과 같은 순서를 밟게 됩니다.

 

step1. 유효한 인가요청인지 검증한다.

 

approve를 유저가 클릭함과 동시에 인가서버에 POST /approve 엔드 포인트로 인가요청이 오게 됩니다.

이때 인가서버는 해당 인가요청이 앞선 인가엔드포인트로 왔던 요청이었는지를 검증해야 합니다.

만약 해당하는 인가엔드 포인트 요청이 없다면 에러 페이지를 보여줍니다.


// 리소스 소유자가 권한 위임을 승인했을 때
app.post('/approve', function(req, res) {
    // 지연된 인가 요청 추출
    var reqid = req.body.reqid;
    var query = requests[reqid];
    delete requests[reqid];

    if(!query) {
       //교차 사이트 위조 공격 일차 방지 == 최초 요청의 식별자와 같은 승인 요청
       res.render('error', {error: "No matching authroization request"});
       return;
    }

 

이제 유저의 반응은 2가지로 구분됩니다. approve 했는가? deny 했는가?

 

만약 권한 위임을 거부했다면 해당 내용을 클라이언트에게 알립니다.

클라이언트의 redirect_url에 몇가지 쿼리 파라미터를 추가해 유저의 웹브라우저를 리다이렉트 시키는 것입니다.

//접근 권 요청 거부 사실을 클라이언트에게 알림
var urlParsed = buildUrl(query.redirect_uri, {
    error: 'access_denied'
});
res.redirect(urlParsed);
return;

 

반대로 approve 되었을 때는 클라이언트가 어떤 종류의 응답을 요청한 것인지 확인해야 합니다. 여기서는 인가코드 그랜트 타입인지를 검증해보겠습니다.

if(req.body.approve) {
    //어떤 종류의 인가요청인지 확인
    if(query.response_type == 'code'){
         ....
    }
}

 

이제 모든 사전검증이 끝났습니다. 인가 코드를 발급해줍시다. 다만, 인가코드는 서버 어딘가에 저장되어야 합니다. 그래야 토큰 엔드 포인트를 통해 토큰을 발급하여 줄 때, 어떤 권한이 인가되었는지를 탐색할 수 있기 때문입니다.

 

//인가코드 만들어 저장해두고 리다이렉트 uri로 리다이렉트 시킴
var code = randomstring.generate(8);
codes[code] = {request:query, scope: rscope};
var urlParsed =  buildUrl(query.redirect_uri, {
    code:code,
    state:query.state
});
res.redirect(urlParsed);

 

 

이제 인가 서버는 클라이언트 에플리케이션에 다시 제어를 넘겨주고 토큰 엔드 포인트로의 요청을 기다리면 됩니다.

 

5.3 토큰 발급

 

이제 클라이언트는 발급된 인가코드를 기반으로 토큰을 발급받기 위해 토큰 엔드 포인트로 자신의 자격증명과 함께 요청을 보냅니다.

 

여기서는 두가지 방식으로 인가코드를 보내는 것을 허용할 것입니다.

1) Authroization 헤더에 포함하는 경우

2) body에 넣어보내주는 경우

//클라이언트 인증 -1 : client id,secret을 Authroziation 헤더로 전달
var auth = req.headers['authorization'];
if(auth) {
    var clientCredentials = decodeClientCredentials(auth);
    var clientId = clientCredentials.id;
    var clientSecret = clientCredentials.secret;
}

//클라이언트 인증 -2 : 폼 파라미터를 통해 전달
if(req.body.client_id) {
    if(clientId) {
       res.status(401).json({error: 'invalid_client'});
       return;
    }
    var clientId = req.body.client_id;
    var clientSecret = req.body.client_secret;
}

//클라이언트 가져오기
var client = getClient(clientId);
if(!client || client.client_secret != clientSecret) {
    res.status(401).json({error: 'invalid_client'});
    return;
}

 

다음으로 그랜트 타입이 우리가 지원하는 타입인지 + 인가코드가 실제로 저장소에 있는지(인가 서버가 발급한 코드인지)를 검증합니다.

전달한 인가코드가 유효하다는 것이 확인되면 그것을 서버 저장소에서 무조건 제거합니다. 인가코드가 탈취되어 사용될 수 있기 때문에 한번 사용된 인가코드를 재활용하지 못하게 하는 것입니다.

//인가 그랜드 요청 처리 - 처리 가능한 인가요청인지 확인
if(req.body.grant_type == 'authorization_code') {
    var code = codes[req.body.code]; //인가 요청 . 다시 확인
    
    if (code) {
    
       delete codes[req.body.code]; //인가코드 삭제 -> 탈취되어 악용될 수 있기 때문
    
       if (code.request_client_id == clientId) {
          
          //엑세스 토큰 발급
          var access_token = randomstring.generate();
          nosql.insert({access_token: access_token, client_id: clientId, scope: code.scope});
          
          var token_response = {
             access_token: access_token,
             token_type: 'Bearer',
             scope: code.scope.join(' ')
          };
          
          res.status(200).json(token_response);
       } else {
          //client_id가 일치하지 않을 때
          res.status(400).json({error: 'invalid_grant'});
          return;
       }
}

 


6. 구현해보며 느낀 OAuth의 핵심철학

 

이것으로 OAuth의 3주체를 간단히 구현해보았습니다. 그 과정에서 느낀 OAuth의 핵심철학은 보안 + 결합도의 약화였습니다. 인가 코드 그랜트 타입을 기준으로 설명하면 OAuth의 3주체인 클라이언트, 인가서버, 리소스 소유자는 각자가 개인의 핵심 관심사에만 집중하게 됩니다. 클라이언트는 권한을 위임받아 자원을 활용하는 것에 / 인가서버는 권한을 위임하여 주는 것에 / 리소스 소유자는 인가를 승인하는 역할을 담당하게 됩니다.

 

그 과정에서 클라이언트는 유저로부터 개인정보나 자격증명 정보를 저장하거나 관리하지 않아도 되며, 유저의 입장에서도 본인의 원하는 범위내의 승인을 하는 등의 권한위임의 주체성을 지닐 수 있게 되었습니다. 즉, 리소스와 클라이언트, 유저 간의 전반적인 결합도가 낮아지는 느낌이 들었습니다.

 

특히, 유저에게 리소스 접근을 위한 자격정보를 물어보지 않아도 된다는 점은 클라이언트 해킹 시 발생할 수 있는 연쇄적인 개인정보 침해를 방지할 수 있다는 장점도 있었습니다. 

 

그 동안 OAuth를 하나의 인증방식으로만 알았었는데, 크나큰 오해임을 알게되었습니다. OAuth은 사용자가 누구인지가 아니라, 권한 위임을 나타내는 인가절차 프로세스일 뿐이라는 개념을 명확히 할 수 있었습니다.