如何基于OAuth2授权框架实现授权服务器(使用Node.js设计开发)

OAuth是一个安全授权框架,支持在不同程序(或者微服务)间的资源访问,也可以在OAuth体系下实现单点登录的功能。OAuth安全框架中包括四个角色:资源拥有者,授权服务器,资源服务器,客户端程序。OAuth安全框架主要说明了这四个角色间的交互流程,以及授权服务器应该具有的能力。OAuth框架是非常灵活和高度可扩展的,一些组件,如token结构组件或者加密算法都是可插拔,可以根据场景需求进行集成。OAuth框架由众多的RFC协议定义,其核心是授权服务器的能力和token的使用,由于其灵活性,其适用范围比较广泛,但是带来的挑战是,如果使用不当,很可能会造成安全隐患。

Spring 对OAuth框架的支持经历了从Spring Security OAuth到Spring Security的迁移,现在社区正在基于新功能开发Spring Authorization Server。 纵观整个过程稍显混乱。

本文基于OAuth in action一书上的设计思想和部分实现,使用JavaScript语言实现授权服务器的功能,包括:基于授权码,客户端密钥,和用户名密码的授权;动态客户端注册,反注册;基于RSA非对称加密的JWT格式的token的一致性检查;token的内省,注销等功能。

基于Node.js的工程目录如下:
如何基于OAuth2授权框架实现授权服务器(使用Node.js设计开发)_第1张图片
main.js文件主要时定义了授权服务器的RESTful接口,其内容如下:

const express = require("express");
const bodyParser = require("body-parser");
const cons = require("consolidate");

const {
     oauthServerPort} = require("./const");
const parseArgv = require("./argv").parseArgv
const {
     initController, registerCallback, approveCallback, authorizeCallback, tokenCallback, getClientConfigCallback,
    deleteClientConfigCallback, introspectCallback, revokeCallback, pubJwkCallback} = require("./controller");

const app = express();

app.use(bodyParser.json());
app.use(bodyParser.urlencoded({
      extended: true })); 
app.use("/", express.static("static"));

app.engine("html", cons.underscore);
app.set("view engine", "html");
app.set("views", "static");
app.set("json spaces", 2);

app.post("/register", registerCallback);
app.get("/authorize", authorizeCallback);
app.post("/approve", approveCallback);
app.post("/token", tokenCallback);
app.post("/introspect", introspectCallback);
app.post("/revoke", revokeCallback);
app.get("/pubjwk", pubJwkCallback);

app.get("/register/:clientId", getClientConfigCallback);
// Not support this operation, as an alternative you can delete the exist client and register a new one.
// app.put("/register/:clientId", updateClientConfigCallback);
app.delete("/register/:clientId", deleteClientConfigCallback);

parseArgv();
initController();

const server = app.listen(oauthServerPort, function() {
     
    let address = server.address().address;
    let port = server.address().port;
    console.log("OAuth server is listening at http://%s:%s", address, port);
});

controller.js是RESTful接口中注册的回调实现,是授权服务器能力的主要实现,其内容为:

const randomstring = require("randomstring");
const __ = require("underscore");
__.string = require("underscore.string");
const crypto = require("crypto");
const base64url = require("base64url");
const {
     md5} = require("request/lib/helpers");
const assert = require("assert");

const consts = require("./const");
const {
     buildUrl, decodeClientCredential} = require("./common");
const {
     initCrypto, signJwtToken, getPubJwk} = require("./crypto");
let {
     clients, tokens, users} = require("./db");
const {
     initDb, insertClient, insertToken, findAndDeleteTokenByRefreshToken, findClientById, deleteClientById, updateClientEntry,
    deleteTokenByAccessToken, findTokenByAccessToken, deleteTokenByClientIdAndGrantType, findUserByName} = require("./db");


// Authorize request and code, I think, not necessary write to database, as the token request
// will come in a short while after these requests.
const codes = {
     };
const requests = {
     };

const checkClientRegisterData = function(req, res) {
     
    console.log("Checking register client data...");
    const register = {
     };
    if (!checkRegisterAllElementLength(req.body)) {
     
        res.status(consts.httpCode400).json({
     error: 'Register info too long.'});
        console.error("Register info too long.");
        return;
    }

    for (let ele_type in req.body) {
     
        if (!checkRegisterElementLength(req.body[ele_type], ele_type)) {
     
            res.status(consts.httpCode400).json({
     error: 'Register info too long of:' + ele_type});
            console.error("Register info too long of:" + ele_type);
            return;
        }
    }

    register.token_endpoint_auth_method = req.body.token_endpoint_auth_method;
    const allowedClientSecretSendMode = [consts.clientSecretSendModeByHeader, consts.clientSecretSendModeByForm];
    if (!__.isString(register.token_endpoint_auth_method) || !__.contains(allowedClientSecretSendMode, register.token_endpoint_auth_method)) {
     
        res.status(consts.httpCode400).json({
     error: 'Invalid client secret send mode.'});
        console.error("Invalid token_endpoint_auth_method:" + register.token_endpoint_auth_method);
        return;
    }

    register.grant_types = req.body.grant_types;
    if (!__.isArray(register.grant_types) || __.isEmpty(register.grant_types)) {
     
        res.status(consts.httpCode400).json({
     error: 'Invalid grant types.'});
        console.error("Invalid grant types:" + register.grant_types);
        return;
    } else {
     
        assert(__.isArray(register.grant_types))
        const allowedGrantType = [consts.grantTypeAuthorizationCode, consts.grantTypeRefreshToken,
        consts.grantTypeClientCredentials, consts.grantTypePassword];
        for (let grantType of register.grant_types) {
     
            if (!__.contains(allowedGrantType, grantType)) {
     
                res.status(consts.httpCode400).json({
     error: 'Invalid grant types.'});
                console.error("Invalid grant types:" + register.grant_types);
                return;
            }
        }
    }

    if (__.contains(register.grant_types, consts.grantTypeAuthorizationCode)) {
     
        if (!req.body.redirect_uris || !__.isArray(req.body.redirect_uris) || __.isEmpty(req.body.redirect_uris)) {
     
            res.status(consts.httpCode400).json({
     error: 'invalid redirect uri.'});
            console.error("Grant type 'authorization_code' has invalid redirect uri:" + req.body.redirect_uris);
            return;
        } else {
     
            // Redirect uri should have path section.
            // TODO: Query section, I think, is not allowed.
            register.redirect_uris = req.body.redirect_uris;
            for (let redirectUri of register.redirect_uris) {
     
                let urlObj = new URL(redirectUri);
                if (__.isEmpty(urlObj.pathname) || urlObj.pathname === '/') {
     
                    res.status(consts.httpCode400).json({
     error: 'invalid redirect uri.'});
                    console.error("Redirect uri SHOULD have specific path section:" + redirectUri);
                    return;
                }
            }

            if (isRedirectUrisInBlackList(register.redirect_uris)) {
     
                res.status(consts.httpCode400).json({
     error: 'invalid redirect uri.'});
                console.error("Redirect uri in black list.");
                return;
            }
        }
    }

    if (typeof (req.body.client_name) === 'string' && !__.isEmpty(req.body.client_name)) {
     
        register.client_name = req.body.client_name;
    } else {
     
        res.status(consts.httpCode400).json({
     error: 'invalid client name.'});
        console.error("Invalid client name:" + req.body.client_name);
        return;
    }

    // Scope is optional.
    register.scope = ""
    if (typeof (req.body.scope) === 'string') {
     
        register.scope = req.body.scope;
    } else if (req.body.scope !== undefined) {
     
        res.status(consts.httpCode400).json({
     error: 'invalid scope format.'});
        console.error("Invalid scope format:" + req.body.scope);
        return;
    }

    if (!__.isEmpty(req.body.client_uri)) {
     
        if (typeof (req.body.client_uri) === 'string') {
     
            register.client_uri = req.body.client_uri;
            if (!__.isEmpty(register.redirect_uris)) {
     
                for (let redirectUri of register.redirect_uris) {
     
                    if (!__.string.startsWith(redirectUri, register.client_uri)) {
     
                        res.status(consts.httpCode400).json({
     error: 'redirect uri SHOULD have the prefix of client uri.'});
                        console.error("redirect uri SHOULD have the prefix of client uri.");
                        return;
                    }
                }
            }
        } else {
     
            res.status(consts.httpCode400).json({
     error: 'invalid client uri format.'});
            console.error("Invalid client uri format.");
            return;
        }
    }

    return register;
};

function isRedirectUrisInBlackList(redirectUris) {
     
    return false;
}

const getScopesFromForm = function(body) {
     
    return __.filter(__.keys(body), function(s) {
      return __.string.startsWith(s, 'scope_'); })
        .map(function(s) {
      return s.slice('scope_'.length); });
};

const getClientById = async function(clientId) {
     
    const client = __.find(clients, function (client) {
     
        return client.client_id === clientId;
    });
    if (client) {
     
        return client;
    }
    return await findClientById(clientId);
};

const authorizeClientManageRequest = async function(req, res) {
     
    const clientId = req.params.clientId;
    const client = await getClientById(clientId);
    if (!client) {
     
        console.error("Invalid client id:" + clientId);
        res.status(consts.httpCode404).end();
        return;
    }

    const auth = req.headers['authorization'];
    if (auth && auth.toLowerCase().indexOf('bearer') === 0) {
     
        const regToken = auth.slice('bearer '.length);
        if (regToken === client.registration_access_token) {
     
            req.client = client;
            return req;
        } else {
     
            console.error("Registration access token mismatch. expected:%s got:%s client id:%s",
                client.registration_access_token, regToken, clientId);
            res.status(consts.httpCode403).end();
        }
    } else {
     
        console.error("Invalid auth:" + auth);
        res.status(consts.httpCode401).end();
    }
};

///
// register endpoint.
function registerCallback(req, res) {
     
    console.log("Register client request is coming.");
    const register = checkClientRegisterData(req, res);
    if (!register) {
     
        // Already send error response.
        return;
    }

    register.client_id = randomstring.generate();
    register.client_secret = randomstring.generate();
    register.client_id_created_at = Math.floor(Date.now() / 1000);
    register.client_secret_expires_at = 0;
    register.registration_access_token = randomstring.generate();
    // TODO: Replace the localhost with ip address.
    register.registration_client_uri = 'http://localhost:9001/register/' + register.client_id;

    saveClientRegisterData(register);
    res.status(consts.httpCode201).json(register);
    console.log("Register the client:\n" + JSON.stringify(register, null, "  "));
}

function saveClientRegisterData(register) {
     
    clients.push(register);
    const length = clients.length;
    console.log("Cached client count:" + length);
    if (length > consts.maxCountOfCachedClients) {
     
        clients.shift();
    }
    insertClient(register);
}

function checkRegisterAllElementLength(body) {
     
    return JSON.stringify(body).length < consts.maxSizeOfRegisterObject;
}

function checkRegisterElementLength(element, type) {
     
    switch (type) {
     
        case "client_name":
            return element.length < consts.maxSizeOfRegisterKeyOfClientName;
        case "redirect_uris":
            return JSON.stringify(element).length < consts.maxSizeOfRegisterKeyOfRedirectUris;
        case "scope":
            return element.length < consts.maxSizeOfRegisterKeyOfScope;
        default:
            return JSON.stringify(element).length < consts.maxSizeOfRegisterDefaultKey;
    }
}

///
// Approve endpoint.
async function approveCallback(req, res) {
     
    console.log("User submitted ack form.");
    const reqid = req.body.reqid;
    const query = requests[reqid];
    delete requests[reqid];
    if (!query) {
     
        res.render('error', {
     error: 'Mismatch authorize request.'});
        return;
    }

    if (req.body.approve) {
     
        console.log("User approved the access.");
        const rscope = getScopesFromForm(req.body);
        const client = await getClientById(query.client_id);
        assert(client);
        const cscope = client.scope ? client.scope.split(' ') : undefined;
        if (__.difference(rscope, cscope).length > 0) {
     
            const urlParsed = buildUrl(query.redirect_uri, {
     
                error: 'invalid_scope'
            });
            res.redirect(urlParsed);
            return;
        }

        // Corner case: If user not select any scope and click the approve button.
        if (__.isEmpty(rscope)) {
     
            const urlParsed = buildUrl(query.redirect_uri, {
     
                error: 'access_denied'
            });
            res.redirect(urlParsed);
            return;
        }

        // Transfer the array to string.
        const scope = rscope.join(" ");
        // Generate the authorize code.
        const code = randomstring.generate(8);
        // Save the code and request.
        codes[code] = {
     request: query, scope: scope};
        const urlParsed = buildUrl(query.redirect_uri, {
     
            code: code,
            state: query.state
        });
        res.redirect(urlParsed);
    } else {
     
        // User denied access.
        console.log("User denied the access.");
        const urlParsed = buildUrl(query.redirect_uri, {
     
            error: 'access_denied'
        });
        res.redirect(urlParsed);
    }
}


// authorize endpoint.
async function authorizeCallback(req, res) {
     
    console.log("Authorize request is coming.");
    const checked = await checkAuthorizeReq(req, res);
    if (__.isEmpty(checked)) {
     
        console.error("authorizeCallback x");
        return;
    }

    const reqId = randomstring.generate(8);
    requests[reqId] = req.query;
    res.render('approve', {
     client: checked.client, reqid: reqId, scope: checked.scope});
}

async function checkAuthorizeReq(req, res) {
     
    const client = await getClientById(req.query.client_id);
    if (__.isEmpty(client)) {
     
        console.error('Unknown client %s.', req.query.client_id);
        res.render('error', {
     error: 'Unknown client'});
        return;
    }

    if (req.query.response_type !== consts.responseTypeOfAuthorizationCode) {
     
        console.error('Authorize request with invalid response type:' + req.query.response_type);
        res.render('error', {
     error: 'invalid response type.'});
        return;
    }

    if (__.isEmpty(req.query.state)) {
     
        console.error('Authorize request without state section.');
        res.render('error', {
     error: 'missing state.'});
        return;
    }

    if (!__.contains(client.redirect_uris, req.query.redirect_uri)) {
     
        console.error('Mismatched redirect URI, expected %s got %s', client.redirect_uris, req.query.redirect_uri);
        res.render('error', {
     error: 'Invalid redirect URI'});
        return;
    }

    const rscope = req.query.scope ? req.query.scope.split(' ') : undefined;
    const cscope = client.scope ? client.scope.split(' ') : undefined;
    if (__.difference(rscope, cscope).length > 0) {
     
        let urlParsed = buildUrl(req.query.redirect_uri, {
     
            error: 'invalid_scope'
        });
        res.redirect(urlParsed);
        return;
    }

    // Use array format to render in approve html page.
    const scope = rscope || cscope;
    return {
     client: client, scope: scope};
}

///
// token endpoint.
async function tokenCallback(req, res) {
     
    console.log("Token post request is coming with grant type:" + req.body.grant_type);

    const client = await checkClientCredential(req, res);
    if (!client) {
     
        return;
    }
    const clientId = client.client_id;

    // Check if the grant type is allowed for the client.
    if (!__.contains(client.grant_types, req.body.grant_type)) {
     
        console.error('Token post request with unsupported grant type:' + req.body.grant_type +
            "\nclient:\n" + JSON.stringify(client));
        res.status(consts.httpCode401).json({
     error: 'invalid_client'});
        return;
    }

    if (req.body.grant_type === consts.grantTypeAuthorizationCode) {
     
        const code = codes[req.body.code];
        if (code) {
     
            delete codes[req.body.code];
            if (code.request.client_id === clientId) {
     
                deleteTokenByClientIdAndGrantType(clientId, consts.grantTypeAuthorizationCode);

                // PKCE verify.
                if (code.request.code_challenge) {
     
                    console.log("Pkce verify. code challenge:%s method:%s", code.request.code_challenge, code.request.code_challenge_method);
                    let code_challenge;
                    if (code.request.code_challenge_method === consts.pkceCodeChallengeMethodPlain) {
     
                        code_challenge = req.body.code_verifier;
                    } else if (code.request.code_challenge_method === consts.pkceCodeChallengeMethodS256) {
     
                        code_challenge = base64url.fromBase64(crypto.createHash('sha256').update(req.body.code_verifier).digest('base64'));
                    } else {
     
                        console.error('Unknown code challenge method:', code.request.code_challenge_method);
                        res.status(consts.httpCode400).json({
     error: 'invalid_request'});
                        return;
                    }

                    if (code.request.code_challenge !== code_challenge) {
     
                        console.error('Code challenge mismatch, expected %s got %s', code.request.code_challenge, code_challenge);
                        res.status(consts.httpCode400).json({
     error: 'invalid_request'});
                        return;
                    }
                }

                let scope = code.scope;
                if (__.isArray(scope)) {
     
                    scope = scope.join(" ");
                }
                const accessTokenFormat = consts.tokenFormatJWT;
                const expire = generateTokenExpire();
                const accessToken = generateAccessToken(accessTokenFormat, scope, expire);
                const refreshToken = generateRefreshToken();
                const tokenInfo = constructTokenInfoWithAuthorizationCode(clientId, scope, accessToken, refreshToken, expire, accessTokenFormat);
                saveTokenInfo(tokenInfo);
                const tokenResponse = {
     
                    access_token: accessToken,
                    token_type: consts.tokenType,
                    refresh_token: refreshToken,
                    scope: scope
                };
                res.status(consts.httpCode200).setHeader(consts.httpResHeaderKeyOfTokenExpire, expire).json(tokenResponse);
                console.log('Issuing access token: %s, refresh token: %s for code:%s', accessToken, refreshToken, req.body.code);
            } else {
     
                console.error('Token post request found client mismatch, expected %s got %s', code.request.client_id, clientId);
                res.status(consts.httpCode400).json({
     error: 'invalid_grant'});
            }
        } else {
     
            console.error('Token post request with unknown code: %s', req.body.code);
            res.status(consts.httpCode400).json({
     error: 'invalid_grant'});
        }
    } else if (req.body.grant_type === consts.grantTypeRefreshToken) {
       // Notice: Allowing the refresh request if the access token is still valid.
        const p = getAndDeleteTokenByRefreshToken(req.body.refresh_token);
        p.then(doc => {
     
            if (__.isEmpty(doc)) {
     
                console.error('Not found refresh token: %s in database.', req.body.refresh_token);
                res.status(consts.httpCode400).json({
     error: 'invalid_grant'});
            } else {
     
                console.log("Found refresh token in record:\n%s", JSON.stringify(doc, null, "  "));
                if (clientId !== doc.client_id) {
     
                    res.status(consts.httpCode400).json({
     error: 'invalid_grant'});
                    console.error("Refresh token belongs to client: %s, but from client: %s", doc.client_id, clientId);
                    // TODO: Take any action to handle these two clients?
                    return;
                }

                // The original grant type, it must be authorization code or password.
                const grantType = doc.grant_type;
                assert(grantType in [consts.grantTypeAuthorizationCode, consts.grantTypePassword]);
                const accessTokenFormat = consts.tokenFormatJWT;
                const expire = generateTokenExpire();
                const accessToken = generateAccessToken(accessTokenFormat, doc.scope, expire);
                const refreshToken = generateRefreshToken();
                let tokenInfo = null;
                if (grantType === consts.grantTypeAuthorizationCode) {
     
                    tokenInfo = constructTokenInfoWithAuthorizationCode(clientId, doc.scope, accessToken, refreshToken, expire, accessTokenFormat);
                } else {
     
                    tokenInfo = constructTokenInfoWithPassword(clientId, doc.user_name, doc.scope, accessToken, refreshToken, expire, accessTokenFormat);
                }
                saveTokenInfo(tokenInfo);
                const tokenResponse = {
     
                    access_token: accessToken,
                    token_type: consts.tokenType,
                    refresh_token: refreshToken,
                    scope: doc.scope
                };
                res.status(consts.httpCode200).setHeader(consts.httpResHeaderKeyOfTokenExpire, expire).json(tokenResponse);
            }
        }).catch(e => {
     
            console.error("Refresh error by:%s msg:%s.", req.body.refresh_token, e.message);
            res.status(consts.httpCode500).json({
     error: 'internal error.'});
        });
    } else if (req.body.grant_type === consts.grantTypeClientCredentials) {
     
        // Notice: Allowing the request if the access token is still valid.
        // Not necessary to generate refresh token for 'client_credentials' request.
        const rscope = req.body.scope ? req.body.scope.split(' ') : undefined;
        const cscope = client.scope ? client.scope.split(' ') : undefined;
        if (__.difference(rscope, cscope).length > 0) {
     
            res.status(consts.httpCode400).json({
     error: 'invalid_scope'});
            console.error("Invalid scope.");
            return;
        }

        // If the request scope is valid, grant it.
        const tokenScope = req.body.scope || client.scope;
        deleteTokenByClientIdAndGrantType(clientId, consts.grantTypeClientCredentials);
        const accessTokenFormat = consts.tokenFormatJWT;
        const expire = generateTokenExpire();
        const accessToken = generateAccessToken(accessTokenFormat, tokenScope, expire);
        const tokenInfo = constructTokenInfoWithClientCredentials(clientId, tokenScope, accessToken, expire, accessTokenFormat);
        saveTokenInfo(tokenInfo);
        const tokenResponse = {
     access_token: accessToken, token_type: consts.tokenType, scope: tokenScope};
        res.status(consts.httpCode200).setHeader(consts.httpResHeaderKeyOfTokenExpire, expire).json(tokenResponse);
    } else if (req.body.grant_type === consts.grantTypePassword) {
     
        const userName = req.body.username;
        const p = getUser(userName);
        p.then(user => {
     
            if (!user) {
     
                res.status(consts.httpCode401).json({
     error: 'invalid_grant'});
                console.error("Token post request with invalid user name:" + userName);
                return;
            }

            // Check password.
            const password = req.body.password;
            if (!checkPasswordForTokenRequest(password, user)) {
     
                console.error('Check password error, user name:%s password:%s', userName, password);
                res.status(consts.httpCode401).json({
     error: 'invalid_grant'});
                return;
            }

            // For grant password, scope is stored in user database.
            const rscope = req.body.scope ? req.body.scope.split(' ') : undefined;
            const uscope = user.scope ? user.scope.split(' ') : undefined;
            if (__.difference(rscope, uscope).length > 0) {
     
                res.status(consts.httpCode401).json({
     error: 'invalid_scope'});
                console.error("Token post request with unsupported scope:" + req.body.scope);
                return;
            }

            deleteTokenByClientIdAndGrantType(clientId, consts.grantTypePassword);

            // The request scope may narrow the allowed scope.
            const tokenScope = req.body.scope || user.scope;
            const accessTokenFormat = consts.tokenFormatJWT;
            const expire = generateTokenExpire();
            const accessToken = generateAccessToken(accessTokenFormat, tokenScope, expire);
            const refreshToken = generateRefreshToken();
            const tokenInfo = constructTokenInfoWithPassword(clientId, userName, tokenScope, accessToken, refreshToken, expire, accessTokenFormat);
            saveTokenInfo(tokenInfo);
            const tokenResponse = {
     
                access_token: accessToken,
                token_type: consts.tokenType,
                refresh_token: refreshToken,
                scope: tokenScope
            };
            res.status(consts.httpCode200).setHeader(consts.httpResHeaderKeyOfTokenExpire, expire).json(tokenResponse);
        }).catch(e => {
     
            console.error("Get user error by name:%s msg:%s.", userName, e.message);
            res.status(consts.httpCode500).json({
     error: 'internal error.'});
        });
    } else {
     
        console.error('Unknown grant type %s', req.body.grant_type);
        res.status(consts.httpCode400).json({
     error: 'unsupported grant type'});
    }
}

function saveTokenInfo(token) {
     
    tokens.push(token);
    const length = tokens.length;
    if (length > consts.maxCountOfCachedTokens) {
     
        tokens.shift();
    }
    insertToken(token);
}

// Notice: Return value is a promise, use 'then()' callback to handle response.
function getAndDeleteTokenByRefreshToken(refreshToken) {
     
    tokens = __.reject(tokens, __.matches({
     refresh_token: refreshToken}));
    return findAndDeleteTokenByRefreshToken(refreshToken);
}

///
// introspection endpoint.
async function introspectCallback(req, res) {
     
    console.log("Introspect request is coming.");
    const auth = req.headers['authorization'];
    if (!auth) {
     
        console.error("No auth header.");
        res.status(consts.httpCode404).end();
        return;
    }
    const credential = decodeClientCredential(auth);
    const clientId = credential.id;
    const clientSecret = credential.secret;

    const client = await getResourceById(clientId);
    if (!client) {
     
        console.error('Unknown resource server id: %s', clientId);
        res.status(consts.httpCode404).end();
        return;
    }

    if (client.client_secret !== clientSecret) {
     
        console.error('Resource secret error, expected %s got %s', client.client_secret, clientSecret);
        res.status(consts.httpCode401).end();
        return;
    }

    const accessToken = req.body.token;
    const tokenInfo = await getTokenByAccessToken(accessToken);
    let expired = false;
    if (tokenInfo) {
     
        console.log("Found access token:%s.", accessToken);
        expired = isTokenExpired(tokenInfo);
        if (!expired) {
     
            const introspectionResponse = {
     
                active: true,
                username: tokenInfo.user_name,
                scope: tokenInfo.scope,
                client_id: tokenInfo.client_id,
                exp: tokenInfo.expire
            };
            res.status(consts.httpCode200).json(introspectionResponse);
            return;
        }
    }

    if (expired) {
     
        console.warn("Access token:%s expired.", accessToken);
    } else {
     
        console.error("Not found access token:%s.", accessToken);
    }

    const introspectionResponse = {
     
        active: false
    };
    res.status(consts.httpCode200).json(introspectionResponse);
}

async function getTokenByAccessToken(accessToken) {
     
    accessToken = md5(accessToken);
    const token = __.find(tokens, function (token) {
     
        return token.access_token === accessToken;
    });
    if (token) {
     
        return token;
    }
    return await findTokenByAccessToken(accessToken);
}

function isTokenExpired(token) {
     
    assert(!__.isEmpty(token.expire));
    return Date.now() > token.expire;
}

async function getResourceById(resourceId) {
     
    return await getClientById(resourceId);
}

///
// revoke endpoint.
async function revokeCallback(req, res) {
     
    console.log("Revoke request is coming.");
    const auth = req.headers['authorization'];
    let clientId;
    let clientSecret;
    if (auth) {
     
        const credential = decodeClientCredential(auth);
        clientId = credential.id;
        clientSecret = credential.secret;
    }

    if (req.body.client_id) {
     
        if (clientId) {
     
            console.warn('Duplicated client secret.');
            res.status(consts.httpCode401).json({
     error: 'invalid_client'});
            return;
        }
        clientId = req.body.client_id;
        clientSecret = req.body.client_secret;
    }

    const client = await getClientById(clientId);
    if (!client) {
     
        console.error('Unknown client: %s', clientId);
        res.status(consts.httpCode401).json({
     error: 'invalid_client'});
        return;
    }

    if (client.client_secret !== clientSecret) {
     
        console.error('Client secret error, expected %s got %s', client.client_secret, clientSecret);
        res.status(consts.httpCode401).json({
     error: 'invalid_client'});
        return;
    }

    const accessToken = req.body.token;
    const tokenInfo = await getTokenByAccessToken(accessToken);
    // Notice: Always return successful response in case of token probe.
    if (!tokenInfo) {
     
        console.error("Not found access token:" + accessToken);
    } else {
     
        removeTokenByAccessToken(accessToken);
    }
    res.status(consts.httpCode204).end();
}

function removeTokenByAccessToken(accessToken) {
     
    accessToken = md5(accessToken);
    tokens = __.reject(tokens, __.matches({
     access_token: accessToken}));
    deleteTokenByAccessToken(accessToken);
}

///
// public JWK endpoint.
async function pubJwkCallback(req, res) {
     
    console.log("Public JWK request is coming.");
    await checkClientCredential();

    const pubjwk = getPubJwk();
    res.status(consts.httpCode200).json({
     pubjwk: pubjwk});
}

// Client credential can be send both in the header and post body, but only one way is present in the same request.
async function checkClientCredential(req, res) {
     
    const auth = req.headers['authorization'];
    let clientId;
    let clientSecret;
    if (auth) {
     
        const credential = decodeClientCredential(auth);
        clientId = credential.id;
        clientSecret = credential.secret;
    }

    if (req.body.client_id) {
     
        if (clientId) {
     
            console.warn('Duplicated client secret.');
            res.status(consts.httpCode401).json({
     error: 'invalid_client'});
            return;
        }
        clientId = req.body.client_id;
        clientSecret = req.body.client_secret;
    }

    const client = await getClientById(clientId);
    if (!client) {
     
        console.error('Unknown client: %s', clientId);
        res.status(consts.httpCode401).json({
     error: 'invalid_client'});
        return;
    }

    if (client.client_secret !== clientSecret) {
     
        console.error('Client secret error, expected %s got %s', client.client_secret, clientSecret);
        res.status(consts.httpCode401).json({
     error: 'invalid_client'});
        return;
    }

    return client;
}

function getUser(userName) {
     
    return findUserByName(userName);
}

function generateTokenExpire(expire = consts.tokenDefaultExpire) {
     
    return Date.now() + expire;
}

function constructTokenInfoWithAuthorizationCode(clientId, scope, accessToken, refreshToken, expire, format) {
     
    return constructTokenInfo(clientId, "", scope, consts.grantTypeAuthorizationCode, format, expire, accessToken, refreshToken);
}

function constructTokenInfoWithClientCredentials(clientId, scope, accessToken, expire, format) {
     
    return constructTokenInfo(clientId, "", scope, consts.grantTypeClientCredentials, format, expire, accessToken, "");
}

function constructTokenInfoWithPassword(clientId, userName, scope, accessToken, refreshToken, expire, format) {
     
    return constructTokenInfo(clientId, userName, scope, consts.grantTypePassword, format, expire, accessToken, refreshToken);
}

function generateAccessToken(format = consts.tokenFormatJWT, scope, expire) {
     
    if (format === consts.tokenFormatJWT) {
     
        return generateJwtAccessToken(scope, expire);
    } else {
     
        return randomstring.generate();
    }
}

function generateRefreshToken() {
     
    return randomstring.generate();
}

function generateJwtAccessToken(scope, expire) {
     
    const header = {
      "typ": "JWT", "alg": "RS256" };
    const payload = {
     
        iat: Math.floor(Date.now() / 1000),
        exp: Math.floor(expire / 1000),
        scope: scope,
        jti: randomstring.generate(8)
    };

    return signJwtToken(header.alg, JSON.stringify(header), JSON.stringify(payload));
}

// Token info: client id, user(depends on grant type), scope, grant type, expire, format, access token, refresh token(optional).
function constructTokenInfo(clientId, userName, scope, grantType, format, expire, accessToken, refreshToken) {
     
    accessToken = md5(accessToken);
    return {
     client_id: clientId, user_name: userName, scope: scope, grant_type: grantType, format: format, expire: expire,
    access_token: accessToken, refresh_token: refreshToken};
}

///
// client config management endpoint.
async function getClientConfigCallback(req, res) {
     
    console.log("Client config get request is coming.");
    const checked = await authorizeClientManageRequest(req, res);
    if (checked) {
     
        // Update the client secret and registration access token each get request.
        req.client.client_secret = randomstring.generate();
        req.registration_access_token = randomstring.generate();
        // Update clients cache and database.
        updateClient(req.client, "client_secret", "registration_access_token");
        res.status(consts.httpCode200).json(req.client);
    }
}

async function updateClient(clientNew, ...keys) {
     
    const client = __.find(clients, function (client) {
     
        return client.client_id === clientNew.client_id;
    });
    if (client) {
     
        for (let key in keys) {
     
            client[key] = clientNew[key];
        }
    }
    await updateClientEntry(clientNew, keys);
}

async function deleteClientConfigCallback(req, res) {
     
    console.log("Client config delete request is coming.");
    const checked = await authorizeClientManageRequest(req, res);
    if (checked) {
     
        clients = __.reject(clients, __.matches({
     client_id: req.client.client_id}));
        deleteClientById(req.client.client_id)
        res.status(consts.httpCode204).end();
    }
}

// TODO:
function removeTokenOfClient(clientId, grantType) {
     
    assert(grantType in [consts.grantTypeAuthorizationCode, consts.grantTypeClientCredentials, consts.grantTypePassword]);
    console.error("Not implemented.");
}

function checkPasswordForTokenRequest(password, user) {
     
    // TODO: Not implemented yet.
    // Load password from user database and compare them.
    return true;
}

function initController() {
     
    initCrypto();
    initDb();
}

module.exports = {
     initController, registerCallback, approveCallback, authorizeCallback, tokenCallback,
    getClientConfigCallback, deleteClientConfigCallback, introspectCallback, revokeCallback, pubJwkCallback};

argv.js文件是程序启动时的命令行参数解析,其内容为:

const assert = require("assert");

const argv = {
     }

function isDevMode() {
     
    return argv.deployMode === "dev";
}

function isProdMode() {
     
    return argv.deployMode === "prod";
}

function parseItem(item) {
     
    const kv = item.trim().split("=");
    assert(kv.length === 2);
    return kv;
}

function parseArgv() {
     
    const argvLength = process.argv.length;
    assert(argvLength > 2)
    for (let i = 2; i < argvLength; i++) {
     
        let item = process.argv[i];
        let kv = parseItem(item);
        switch (kv[0]) {
     
            case "deploy":
                argv.deployMode = kv[1];
                break;
            case "mongo_type":
                argv.mongoType = kv[1];
                break;
            case "mongo_atlas_user":
                argv.mongoAtlasUser = kv[1];
                break;
            case "mongo_atlas_password":
                argv.mongoAtlasPassword = kv[1];
                break;
            case "mongo_local_user":
                argv.mongoLocalUser = kv[1];
                break;
            case "mongo_local_password":
                argv.mongoLocalPassword = kv[1];
                break;
            case "mongo_local_uri":
                argv.mongoLocalUri = kv[1];
                break;
            case "del_col":
                argv.deleteCollection = kv[1];
                break;
            default:
                console.warn("Unsupported input parameter:" + kv[0]);
                break;
        }
    }

    if (isDevMode()) {
     
        console.log("In dev mode.");
    } else {
     
        console.log("In prod mode.");
    }
}

exports.argv = argv;
exports.parseArgv = parseArgv;
exports.isDevMode = isDevMode;
exports.isProdMode = isProdMode;

文件const.js是一些常量定义,其内容为:

const oauthServerPort = 9001;

const httpCode200 = 200;
const httpCode201 = 201;
const httpCode204 = 204;
const httpCode400 = 400;
const httpCode401 = 401;
const httpCode403 = 403;
const httpCode404 = 404;
const httpCode500 = 500;

// It is the access token expire,
// no expire for refresh token as we always allocate a new refresh token when grant type is refresh_token.
const tokenDefaultExpire = 86400000 * 2; // 2 days.

const tokenFormatPlain = "plain";
const tokenFormatJWT = "jwt";

const tokenType = "Bearer";

const grantTypeAuthorizationCode = "authorization_code";
const grantTypeRefreshToken = "refresh_token";
const grantTypeClientCredentials = "client_credentials";
const grantTypePassword = "password";

const httpResHeaderKeyOfTokenExpire = "expire-in";

const responseTypeOfAuthorizationCode = "code";

const maxSizeOfRegisterObject = 4096;
const maxSizeOfRegisterKeyOfClientName = 64;
const maxSizeOfRegisterKeyOfRedirectUris = 1024;
const maxSizeOfRegisterKeyOfScope = 2048;
const maxSizeOfRegisterDefaultKey = 1024;

const clientSecretSendModeByHeader = "secret_basic";
const clientSecretSendModeByForm = "secret_post";

const maxCountOfCachedClients = 1000;
const maxCountOfCachedTokens = 1000;
const maxCountOfCachedUsers = 2000;

const pkceCodeChallengeMethodPlain = "plain";
const pkceCodeChallengeMethodS256 = "S256";

module.exports = {
     oauthServerPort, tokenDefaultExpire, tokenFormatPlain, tokenFormatJWT, tokenType, grantTypeAuthorizationCode,
grantTypeClientCredentials, grantTypeRefreshToken, grantTypePassword, httpResHeaderKeyOfTokenExpire,
    responseTypeOfAuthorizationCode, maxSizeOfRegisterObject, maxSizeOfRegisterKeyOfClientName,
    maxSizeOfRegisterKeyOfRedirectUris, maxSizeOfRegisterKeyOfScope, maxSizeOfRegisterDefaultKey,
    clientSecretSendModeByHeader, clientSecretSendModeByForm, httpCode200, httpCode201, httpCode204, httpCode400, httpCode401,
    httpCode403, httpCode404, httpCode500, maxCountOfCachedClients, maxCountOfCachedTokens, maxCountOfCachedUsers,
    pkceCodeChallengeMethodPlain, pkceCodeChallengeMethodS256};

文件crypto.js是支持JWT格式token的加密和完整性检查的逻辑,其内容为:

const rs = require("jsrsasign");
const rsu = require("jsrsasign-util");
const __ = require("underscore");
const assert = require("assert");
const base64url = require("base64url");

const pubKeyPemFile = "crypto.pub.pem";
const prvKeyPemFile = "crypto.prv.pem";

let prvKey;
let pubJWK;

function initCrypto() {
     
    console.log("Init crypto...");
    const pubPemStr = rsu.readFile(pubKeyPemFile);
    const pubKeyLoaded = rs.KEYUTIL.getKey(pubPemStr);
    pubJWK = rs.KEYUTIL.getJWKFromKey(pubKeyLoaded);

    const prvPemStr = rsu.readFile(prvKeyPemFile);
    const prvKeyLoaded = rs.KEYUTIL.getKey(prvPemStr);
    const prvJWK = rs.KEYUTIL.getJWKFromKey(prvKeyLoaded);

    prvKey = rs.KEYUTIL.getKey(prvJWK);
}

function signJwtToken(alg, header, payload) {
     
    assert(!__.isEmpty(prvKey));
    return rs.jws.JWS.sign(alg, header, payload, prvKey);
}

function getPubJwk() {
     
    assert(!__.isEmpty(pubJWK));
    return JSON.stringify(pubJWK);
}

module.exports = {
     initCrypto, signJwtToken, getPubJwk};

文件db.js是OAuth授权服务器后端存储,本文使用MongoDB存储动态客户端数据,token数据,用户凭据信息等等。其内容为:

const __ = require("underscore");
const {
      MongoClient } = require('mongodb');
const assert = require("assert");

const argv = require("./argv").argv
const {
     maxCountOfCachedClients, maxCountOfCachedTokens, maxCountOfCachedUsers} = require("./const");

let clientDriver;
let oauthCollectionClient;
let oauthCollectionToken;
let userCollectionInfo;

// These 3 array are the memory cache of the given database store.
// Notice: If the resource server check token using introspection, it should register itself to
// authorization server like client when startup. Here we use the same cache to save these two kind of
// register information.
let clients = [];
let tokens = [];
let users = [];

function useMongoAtlas() {
     
    return argv.mongoType === "atlas";
}

function useMongoLocal() {
     
    return argv.mongoType === "local";
}

async function getClientDriver() {
     
    let uri = null;
    if (useMongoAtlas()) {
     
        console.log("Using mongo atlas.");
        assert(typeof argv.mongoAtlasUser === "string");
        assert(typeof argv.mongoAtlasPassword === "string");

        uri = `mongodb+srv://${
       argv.mongoAtlasUser}:${
       argv.mongoAtlasPassword}@cluster-fred.emwlw.mongodb.net/?retryWrites=true&w=majority`;
    } else {
     
        assert(false, "Mongo local NOT deploy yet.");
        console.log("Using mongo local.");
        assert(typeof argv.mongoLocalUser === "string");
        assert(typeof argv.mongoLocalPassword === "string");
        assert(typeof argv.mongoLocalUri === "string");
    }

    console.log("Create mongo driver client from uri:" + uri);
    clientDriver = new MongoClient(uri);
    try {
     
        await clientDriver.connect();
    } catch (e) {
     
        console.error("Connection db error:" + e);
        process.exit(1);
    }
}

function OpenOauthDb() {
     
    const oauthDb = clientDriver.db("oauth");
    oauthCollectionClient = oauthDb.collection("client");
    oauthCollectionToken = oauthDb.collection("token");
    console.log("Open oauth database.");
}

function OpenUserDb() {
     
    const userDb = clientDriver.db("user");
    userCollectionInfo = userDb.collection("info");
    console.log("Open user database.");
}

async function dropCollectionIfNecessary() {
     
    if (argv.deleteCollection !== undefined) {
     
        assert(typeof (argv.deleteCollection) === "string");
        const items = argv.deleteCollection.trim().split("&");
        console.log("Clear collection:%s when startup.", argv.deleteCollection);
        assert(items.length > 0);
        for (let item of items) {
     
            const pair = item.split(":");
            try {
     
                await clientDriver.db(pair[0]).dropCollection(pair[1]);
            } catch (e) {
     
                console.error("Drop collection:%s:%s error:%s.", pair[0], pair[1], e.message);
            }
        }
    }
}

async function insertClient(client) {
     
    const result = await oauthCollectionClient.insertOne(client);
    console.log(`Client inserted with id: ${
       result.insertedId}`);
}

async function insertToken(token) {
     
    const result = await oauthCollectionToken.insertOne(token);
    console.log(`Token inserted with id: ${
       result.insertedId}`);
}

// For refresh token grant type, always allocate the new access token and refresh token,
// so we delete the previous token record.
// Notice: the return value is a promise, call the 'then()' to handle the callback of database.
function findAndDeleteTokenByRefreshToken(refreshToken) {
     
    const query = {
      refresh_token: refreshToken };
    return oauthCollectionToken.findOneAndDelete(query);
}

async function findTokenByAccessToken(accessToken) {
     
    const query = {
     access_token: accessToken};
    const result = await oauthCollectionToken.findOne(query);
    console.log("Find " + (result == null ? 0 : 1) + " token by access token:" + accessToken);
    return result;
}

async function deleteTokenByRefreshToken(refreshToken) {
     
    const query = {
      refresh_token: refreshToken };
    const result = await oauthCollectionToken.deleteOne(query);
    console.log("Deleted " + result.deletedCount + " token by refresh token:" + refreshToken);
}

async function deleteTokenByAccessToken(accessToken) {
     
    const query = {
     access_token: accessToken};
    const result = await oauthCollectionToken.deleteOne(query);
    console.log("Deleted " + result.deletedCount + " token by access token:" + accessToken);
}

async function deleteTokenByClientId(clientId) {
     
    const query = {
      client_id: clientId };
    const result = await oauthCollectionToken.deleteMany(query);
    console.log("Deleted " + result.deletedCount + " tokens by client id:" + clientId);
}

async function deleteTokenByClientIdAndGrantType(clientId, grantType) {
     
    const query = {
      client_id: clientId, grant_type: grantType };
    const result = await oauthCollectionToken.deleteMany(query);
    console.log("Deleted " + result.deletedCount + " tokens by client id:" + clientId + " and grant type:" + grantType);
}

async function deleteClientById(clientId) {
     
    await deleteTokenByClientId(clientId)
    const query = {
      client_id: clientId };
    const result = await oauthCollectionClient.deleteOne(query);
    console.log("Deleted " + result.deletedCount + " client by client id:" + clientId);
}

async function updateClientEntry(client, keys) {
     
    assert(!__.isEmpty(keys));
    assert(__.isArray(keys));

    const query = {
     client_id: client.client_id};
    const kv = {
     };
    for (let key of keys) {
     
        kv[key] = client[key];
    }
    const updateDocument = {
     
        $set: kv,
    };
    const result = await oauthCollectionClient.updateOne(query, updateDocument);
    console.log("Update matched client count:" + result.matchedCount);
}

async function findClientById(clientId) {
     
    const query = {
      client_id: clientId };
    const result = await oauthCollectionClient.findOne(query);
    console.log("Find " + (result == null ? 0 : 1) + " client by client id:" + clientId);
    return result;
}

// Notice: the return value is a promise, call the 'then()' to handle the callback of database.
function findUserByName(userName) {
     
    const query = {
     user_name: userName};
    return userCollectionInfo.findOne(query);
}

async function loadCollections() {
     
    const loads = {
     };

    const name = ["client", "token", "user"];
    const clientLoadOptions = {
     sort: {
      client_id_created_at: -1 }, limit: maxCountOfCachedClients};
    const clientCursor = oauthCollectionClient.find({
     }, clientLoadOptions);

    const tokenLoadOptions = {
     sort: {
      expire: 1 }, limit: maxCountOfCachedTokens};
    const tokenCursor = oauthCollectionToken.find({
     }, tokenLoadOptions);

    const userLoadOptions = {
     limit: maxCountOfCachedUsers};
    const userCursor = userCollectionInfo.find({
     }, userLoadOptions);

    const p = Promise.allSettled([clientCursor, tokenCursor, userCursor]);
    p.then(async cursors => {
     
        for (let i = 0; i < 3; i++) {
     
            if (cursors[i].status === "rejected") {
     
                console.error("Load collection:%s error, reason:%s.", name[i], cursors[i].reason);
            }

            switch (i) {
     
                case 0:
                    if (cursors[i].status === "fulfilled") {
     
                        loads.client = await cursors[i].value.toArray();
                    }
                    await cursors[i].value.close();
                    break;
                case 1:
                    if (cursors[i].status === "fulfilled") {
     
                        loads.token = await cursors[i].value.toArray();
                    }
                    await cursors[i].value.close();
                    break;
                case 2:
                    if (cursors[i].status === "fulfilled") {
     
                        loads.user = await cursors[i].value.toArray();
                    }
                    await cursors[i].value.close();
                    break;
                default:
                    console.error("Internal logic error of collection data load.");
                    break;
            }
        }

        if (!__.isEmpty(loads.client)) {
     
            if (__.isArray(loads.client)) {
     
                clients.push(...loads.client);
            } else {
     
                clients.push(loads.client);
            }
        }
        if (!__.isEmpty(loads.token)) {
     
            if (__.isArray(loads.token)) {
     
                tokens.push(...loads.token);
            } else {
     
                tokens.push(loads.token);
            }
        }
        if (!__.isEmpty(loads.user)) {
     
            if (__.isArray(loads.user)) {
     
                users.push(...loads.user);
            } else {
     
                users.push(loads.user);
            }
        }
        console.log("Loaded database collections. client count:%d, token count:%d, user count:%d.",
            clients.length, tokens.length, users.length);
    }).catch(e => {
     
        console.error("Load collection error:" + e.message);
    });
}

async function initDb() {
     
    await getClientDriver();
    await dropCollectionIfNecessary();
    OpenOauthDb();
    OpenUserDb();
    return loadCollections();
}

module.exports = {
     clients, tokens, users, initDb, findClientById, insertClient, deleteClientById, updateClientEntry,
    insertToken, findTokenByAccessToken, findAndDeleteTokenByRefreshToken, deleteTokenByAccessToken,
    deleteTokenByClientIdAndGrantType, findUserByName};

你可能感兴趣的:(MongoDB,OAuth,Node.js,node.js,oauth2,mongodb,网络)