Add Digest Auth Support (MD5)

This commit is contained in:
Tasos Piotopoulos 2023-10-28 15:59:50 +01:00
parent 5274d77660
commit 3838848b03
25 changed files with 401 additions and 6 deletions

View File

@ -61,6 +61,15 @@ const AuthMode = ({ collection }) => {
>
Bearer Token
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('digest');
}}
>
Digest Auth
</div>
<div
className="dropdown-item"
onClick={() => {

View File

@ -0,0 +1,16 @@
import styled from 'styled-components';
const Wrapper = styled.div`
label {
font-size: 0.8125rem;
}
.single-line-editor-wrapper {
padding: 0.15rem 0.4rem;
border-radius: 3px;
border: solid 1px ${(props) => props.theme.input.border};
background-color: ${(props) => props.theme.input.bg};
}
`;
export default Wrapper;

View File

@ -0,0 +1,71 @@
import React from 'react';
import get from 'lodash/get';
import { useTheme } from 'providers/Theme';
import { useDispatch } from 'react-redux';
import SingleLineEditor from 'components/SingleLineEditor';
import { updateCollectionAuth } from 'providers/ReduxStore/slices/collections';
import { saveCollectionRoot } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
const DigestAuth = ({ collection }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const digestAuth = get(collection, 'root.request.auth.digest', {});
const handleSave = () => dispatch(saveCollectionRoot(collection.uid));
const handleUsernameChange = (username) => {
dispatch(
updateCollectionAuth({
mode: 'digest',
collectionUid: collection.uid,
content: {
username: username,
password: digestAuth.password
}
})
);
};
const handlePasswordChange = (password) => {
dispatch(
updateCollectionAuth({
mode: 'digest',
collectionUid: collection.uid,
content: {
username: digestAuth.username,
password: password
}
})
);
};
return (
<StyledWrapper className="mt-2 w-full">
<label className="block font-medium mb-2">Username</label>
<div className="single-line-editor-wrapper mb-2">
<SingleLineEditor
value={digestAuth.username || ''}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handleUsernameChange(val)}
collection={collection}
/>
</div>
<label className="block font-medium mb-2">Password</label>
<div className="single-line-editor-wrapper">
<SingleLineEditor
value={digestAuth.password || ''}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handlePasswordChange(val)}
collection={collection}
/>
</div>
</StyledWrapper>
);
};
export default DigestAuth;

View File

@ -5,6 +5,7 @@ import AuthMode from './AuthMode';
import AwsV4Auth from './AwsV4Auth';
import BearerAuth from './BearerAuth';
import BasicAuth from './BasicAuth';
import DigestAuth from './DigestAuth';
import { saveCollectionRoot } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
@ -25,6 +26,9 @@ const Auth = ({ collection }) => {
case 'bearer': {
return <BearerAuth collection={collection} />;
}
case 'digest': {
return <DigestAuth collection={collection} />;
}
}
};

View File

@ -62,6 +62,15 @@ const AuthMode = ({ item, collection }) => {
>
Bearer Token
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('digest');
}}
>
Digest Auth
</div>
<div
className="dropdown-item"
onClick={() => {

View File

@ -0,0 +1,16 @@
import styled from 'styled-components';
const Wrapper = styled.div`
label {
font-size: 0.8125rem;
}
.single-line-editor-wrapper {
padding: 0.15rem 0.4rem;
border-radius: 3px;
border: solid 1px ${(props) => props.theme.input.border};
background-color: ${(props) => props.theme.input.bg};
}
`;
export default Wrapper;

View File

@ -0,0 +1,76 @@
import React from 'react';
import get from 'lodash/get';
import { useTheme } from 'providers/Theme';
import { useDispatch } from 'react-redux';
import SingleLineEditor from 'components/SingleLineEditor';
import { updateAuth } from 'providers/ReduxStore/slices/collections';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
const DigestAuth = ({ item, collection }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const digestAuth = item.draft ? get(item, 'draft.request.auth.digest', {}) : get(item, 'request.auth.digest', {});
const handleRun = () => dispatch(sendRequest(item, collection.uid));
const handleSave = () => dispatch(saveRequest(item.uid, collection.uid));
const handleUsernameChange = (username) => {
dispatch(
updateAuth({
mode: 'digest',
collectionUid: collection.uid,
itemUid: item.uid,
content: {
username: username,
password: digestAuth.password
}
})
);
};
const handlePasswordChange = (password) => {
dispatch(
updateAuth({
mode: 'digest',
collectionUid: collection.uid,
itemUid: item.uid,
content: {
username: digestAuth.username,
password: password
}
})
);
};
return (
<StyledWrapper className="mt-2 w-full">
<label className="block font-medium mb-2">Username</label>
<div className="single-line-editor-wrapper mb-2">
<SingleLineEditor
value={digestAuth.username || ''}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handleUsernameChange(val)}
onRun={handleRun}
collection={collection}
/>
</div>
<label className="block font-medium mb-2">Password</label>
<div className="single-line-editor-wrapper">
<SingleLineEditor
value={digestAuth.password || ''}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handlePasswordChange(val)}
onRun={handleRun}
collection={collection}
/>
</div>
</StyledWrapper>
);
};
export default DigestAuth;

View File

@ -4,6 +4,7 @@ import AuthMode from './AuthMode';
import AwsV4Auth from './AwsV4Auth';
import BearerAuth from './BearerAuth';
import BasicAuth from './BasicAuth';
import DigestAuth from './DigestAuth';
import StyledWrapper from './StyledWrapper';
const Auth = ({ item, collection }) => {
@ -20,6 +21,9 @@ const Auth = ({ item, collection }) => {
case 'bearer': {
return <BearerAuth collection={collection} item={item} />;
}
case 'digest': {
return <DigestAuth collection={collection} item={item} />;
}
}
};

View File

@ -388,6 +388,10 @@ export const collectionsSlice = createSlice({
item.draft.request.auth.mode = 'basic';
item.draft.request.auth.basic = action.payload.content;
break;
case 'digest':
item.draft.request.auth.mode = 'digest';
item.draft.request.auth.digest = action.payload.content;
break;
}
}
}
@ -976,6 +980,9 @@ export const collectionsSlice = createSlice({
case 'basic':
set(collection, 'root.request.auth.basic', action.payload.content);
break;
case 'digest':
set(collection, 'root.request.auth.digest', action.payload.content);
break;
}
}
},

View File

@ -494,6 +494,10 @@ export const humanizeRequestAuthMode = (mode) => {
label = 'Bearer Token';
break;
}
case 'digest': {
label = 'Digest Auth';
break;
}
}
return label;

View File

@ -80,7 +80,8 @@ const transformInsomniaRequestItem = (request, index, allRequests) => {
auth: {
mode: 'none',
basic: null,
bearer: null
bearer: null,
digest: null
},
headers: [],
params: [],

View File

@ -69,7 +69,8 @@ const transformOpenapiRequestItem = (request) => {
auth: {
mode: 'none',
basic: null,
bearer: null
bearer: null,
digest: null
},
headers: [],
params: [],

View File

@ -0,0 +1,79 @@
const crypto = require('crypto');
function isStrPresent(str) {
return str && str !== '' && str !== 'undefined';
}
function stripQuotes(str) {
return str.replace(/"/g, '');
}
function containsDigestHeader(response) {
const authHeader = response?.headers?.['www-authenticate'];
return authHeader ? authHeader.trim().toLowerCase().startsWith('digest') : false;
}
function containsAuthorizationHeader(originalRequest) {
return Boolean(originalRequest.headers['Authorization']);
}
function md5(input) {
return crypto.createHash('md5').update(input).digest('hex');
}
function addDigestInterceptor(axiosInstance, request) {
const { username, password } = request.digestConfig;
console.debug(request);
if (!isStrPresent(username) || !isStrPresent(password)) {
console.warn('Required Digest Auth fields are not present');
return;
}
axiosInstance.interceptors.response.use(
(response) => response,
(error) => {
const originalRequest = error.config;
if (
error.response?.status === 401 &&
containsDigestHeader(error.response) &&
!containsAuthorizationHeader(originalRequest)
) {
console.debug(error.response.headers['www-authenticate']);
const authDetails = error.response.headers['www-authenticate']
.split(', ')
.map((v) => v.split('=').map(stripQuotes))
.reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {});
console.debug(authDetails);
const nonceCount = '00000001';
const cnonce = crypto.randomBytes(24).toString('hex');
if (authDetails.algorithm.toUpperCase() !== 'MD5') {
console.warn(`Unsupported Digest algorithm: ${algo}`);
return Promise.reject(error);
}
const HA1 = md5(`${username}:${authDetails['Digest realm']}:${password}`);
const HA2 = md5(`${request.method}:${request.url}`);
const response = md5(`${HA1}:${authDetails.nonce}:${nonceCount}:${cnonce}:auth:${HA2}`);
const authorizationHeader =
`Digest username="${username}",realm="${authDetails['Digest realm']}",` +
`nonce="${authDetails.nonce}",uri="${request.url}",qop="auth",algorithm="${authDetails.algorithm}",` +
`response="${response}",nc="${nonceCount}",cnonce="${cnonce}"`;
originalRequest.headers['Authorization'] = authorizationHeader;
console.debug(`Authorization: ${originalRequest.headers['Authorization']}`);
delete originalRequest.digestConfig;
return axiosInstance(originalRequest);
}
return Promise.reject(error);
}
);
}
module.exports = { addDigestInterceptor };

View File

@ -23,6 +23,7 @@ const { HttpProxyAgent } = require('http-proxy-agent');
const { SocksProxyAgent } = require('socks-proxy-agent');
const { makeAxiosInstance } = require('./axios-instance');
const { addAwsV4Interceptor, resolveAwsV4Credentials } = require('./awsv4auth-helper');
const { addDigestInterceptor } = require('./digestauth-helper');
const { shouldUseProxy, PatchedHttpsProxyAgent } = require('../../utils/proxy-util');
// override the default escape function to prevent escaping
@ -168,6 +169,10 @@ const configureRequest = async (collectionUid, request, envVars, collectionVaria
delete request.awsv4config;
}
if (request.digestConfig) {
addDigestInterceptor(axiosInstance, request);
}
request.timeout = preferencesUtil.getRequestTimeout();
return axiosInstance;

View File

@ -131,6 +131,12 @@ const interpolateVars = (request, envVars = {}, collectionVariables = {}, proces
request.awsv4config.profileName = interpolate(request.awsv4config.profileName) || '';
}
// interpolate vars for digest auth
if (request.digestConfig) {
request.digestConfig.username = interpolate(request.digestConfig.username) || '';
request.digestConfig.password = interpolate(request.digestConfig.password) || '';
}
return request;
};

View File

@ -28,6 +28,12 @@ const setAuthHeaders = (axiosRequest, request, collectionRoot) => {
case 'bearer':
axiosRequest.headers['authorization'] = `Bearer ${get(collectionAuth, 'bearer.token')}`;
break;
case 'digest':
axiosRequest.digestConfig = {
username: get(collectionAuth, 'digest.username'),
password: get(collectionAuth, 'digest.password')
};
break;
}
}
@ -52,6 +58,11 @@ const setAuthHeaders = (axiosRequest, request, collectionRoot) => {
case 'bearer':
axiosRequest.headers['authorization'] = `Bearer ${get(request, 'auth.bearer.token')}`;
break;
case 'digest':
axiosRequest.digestConfig = {
username: get(request, 'auth.digest.username'),
password: get(request, 'auth.digest.password')
};
}
}

View File

@ -23,7 +23,7 @@ const { outdentString } = require('../../v1/src/utils');
*/
const grammar = ohm.grammar(`Bru {
BruFile = (meta | http | query | headers | auths | bodies | varsandassert | script | tests | docs)*
auths = authawsv4 | authbasic | authbearer
auths = authawsv4 | authbasic | authbearer | authdigest
bodies = bodyjson | bodytext | bodyxml | bodysparql | bodygraphql | bodygraphqlvars | bodyforms | body
bodyforms = bodyformurlencoded | bodymultipart
@ -79,6 +79,7 @@ const grammar = ohm.grammar(`Bru {
authawsv4 = "auth:awsv4" dictionary
authbasic = "auth:basic" dictionary
authbearer = "auth:bearer" dictionary
authdigest = "auth:digest" dictionary
body = "body" st* "{" nl* textblock tagend
bodyjson = "body:json" st* "{" nl* textblock tagend
@ -350,6 +351,21 @@ const sem = grammar.createSemantics().addAttribute('ast', {
}
};
},
authdigest(_1, dictionary) {
const auth = mapPairListToKeyValPairs(dictionary.ast, false);
const usernameKey = _.find(auth, { name: 'username' });
const passwordKey = _.find(auth, { name: 'password' });
const username = usernameKey ? usernameKey.value : '';
const password = passwordKey ? passwordKey.value : '';
return {
auth: {
digest: {
username,
password
}
}
};
},
bodyformurlencoded(_1, dictionary) {
return {
body: {

View File

@ -4,7 +4,7 @@ const { outdentString } = require('../../v1/src/utils');
const grammar = ohm.grammar(`Bru {
BruFile = (meta | query | headers | auth | auths | vars | script | tests | docs)*
auths = authawsv4 | authbasic | authbearer
auths = authawsv4 | authbasic | authbearer | authdigest
nl = "\\r"? "\\n"
st = " " | "\\t"
@ -41,6 +41,7 @@ const grammar = ohm.grammar(`Bru {
authawsv4 = "auth:awsv4" dictionary
authbasic = "auth:basic" dictionary
authbearer = "auth:bearer" dictionary
authdigest = "auth:digest" dictionary
script = scriptreq | scriptres
scriptreq = "script:pre-request" st* "{" nl* textblock tagend
@ -226,6 +227,21 @@ const sem = grammar.createSemantics().addAttribute('ast', {
}
};
},
authdigest(_1, dictionary) {
const auth = mapPairListToKeyValPairs(dictionary.ast, false);
const usernameKey = _.find(auth, { name: 'username' });
const passwordKey = _.find(auth, { name: 'password' });
const username = usernameKey ? usernameKey.value : '';
const password = passwordKey ? passwordKey.value : '';
return {
auth: {
digest: {
username,
password
}
}
};
},
varsreq(_1, dictionary) {
const vars = mapPairListToKeyValPairs(dictionary.ast);
_.each(vars, (v) => {

View File

@ -114,6 +114,15 @@ ${indentString(`password: ${auth.basic.password}`)}
${indentString(`token: ${auth.bearer.token}`)}
}
`;
}
if (auth && auth.digest) {
bru += `auth:digest {
${indentString(`username: ${auth.digest.username}`)}
${indentString(`password: ${auth.digest.password}`)}
}
`;
}

View File

@ -102,6 +102,15 @@ ${indentString(`password: ${auth.basic.password}`)}
${indentString(`token: ${auth.bearer.token}`)}
}
`;
}
if (auth && auth.digest) {
bru += `auth:digest {
${indentString(`username: ${auth.digest.username}`)}
${indentString(`password: ${auth.digest.password}`)}
}
`;
}

View File

@ -21,6 +21,11 @@ auth:bearer {
token: 123
}
auth:digest {
username: john
password: secret
}
vars:pre-request {
departingDate: 2020-01-01
~returningDate: 2020-01-02

View File

@ -27,6 +27,10 @@
},
"bearer": {
"token": "123"
},
"digest": {
"username": "john",
"password": "secret"
}
},
"vars": {

View File

@ -40,6 +40,11 @@ auth:bearer {
token: 123
}
auth:digest {
username: john
password: secret
}
body:json {
{
"hello": "world"

View File

@ -59,6 +59,10 @@
},
"bearer": {
"token": "123"
},
"digest": {
"username": "john",
"password": "secret"
}
},
"body": {

View File

@ -94,11 +94,19 @@ const authBearerSchema = Yup.object({
.noUnknown(true)
.strict();
const authDigestSchema = Yup.object({
username: Yup.string().nullable(),
password: Yup.string().nullable()
})
.noUnknown(true)
.strict();
const authSchema = Yup.object({
mode: Yup.string().oneOf(['none', 'awsv4', 'basic', 'bearer']).required('mode is required'),
mode: Yup.string().oneOf(['none', 'awsv4', 'basic', 'bearer', 'digest']).required('mode is required'),
awsv4: authAwsV4Schema.nullable(),
basic: authBasicSchema.nullable(),
bearer: authBearerSchema.nullable()
bearer: authBearerSchema.nullable(),
digest: authDigestSchema.nullable()
})
.noUnknown(true)
.strict();