mirror of
https://github.com/Joxit/docker-registry-ui.git
synced 2025-04-27 07:29:54 +03:00
feat(token-auth-keycloak): add token auth via keycloak using docker protocol
This commit is contained in:
parent
2c9f006b8c
commit
58b1486c81
8 changed files with 180 additions and 44 deletions
|
@ -22,10 +22,11 @@ services:
|
|||
- registry-ui-net
|
||||
|
||||
ui:
|
||||
image: joxit/docker-registry-ui:static
|
||||
image: joxit/docker-registry-ui
|
||||
environment:
|
||||
REGISTRY_TITLE: My Private Docker Registry
|
||||
URL: http://localhost
|
||||
REGISTRY_URL: http://localhost
|
||||
SINGLE_REGISTRY: 'true'
|
||||
networks:
|
||||
- registry-ui-net
|
||||
|
||||
|
|
|
@ -55,7 +55,9 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|||
display(props, state) {
|
||||
let repositories = [];
|
||||
const self = this;
|
||||
const oReq = new Http();
|
||||
const oReq = new Http({
|
||||
onAuthentication: this.props.onAuthentication
|
||||
});
|
||||
oReq.addEventListener('load', function () {
|
||||
if (this.status == 200) {
|
||||
repositories = JSON.parse(this.responseText).repositories || [];
|
||||
|
|
71
src/components/dialogs/registry-authentication.riot
Normal file
71
src/components/dialogs/registry-authentication.riot
Normal file
|
@ -0,0 +1,71 @@
|
|||
<!--
|
||||
Copyright (C) 2016-2021 Jones Magloire @Joxit
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
-->
|
||||
<registry-authentication>
|
||||
<material-popup opened="{ props.opened }" onClick="{ props.onClose }">
|
||||
<div slot="title">Sign In to your registry</div>
|
||||
<div slot="content">
|
||||
<material-input placeholder="Username" id="username"></material-input>
|
||||
<material-input placeholder="Password" id="password" onkeyup="{ onKeyUp }" type="password"></material-input>
|
||||
</div>
|
||||
<div slot="action">
|
||||
<material-button class="dialog-button" waves-color="rgba(158,158,158,.4)" onClick="{ signIn }">
|
||||
Sign In
|
||||
</material-button>
|
||||
<material-button class="dialog-button" waves-color="rgba(158,158,158,.4)" onClick="{ props.onClose }">
|
||||
Cancel
|
||||
</material-button>
|
||||
</div>
|
||||
</material-popup>
|
||||
<script>
|
||||
export default {
|
||||
signIn() {
|
||||
const {
|
||||
realm,
|
||||
service,
|
||||
scope,
|
||||
onAuthenticated,
|
||||
onClose
|
||||
} = this.props;
|
||||
const username = this.$('#username input').value;
|
||||
const password = this.$('#password input').value;
|
||||
const req = new XMLHttpRequest()
|
||||
req.addEventListener('loadend', () => {
|
||||
try {
|
||||
const bearer = JSON.parse(req.responseText);
|
||||
onAuthenticated(bearer)
|
||||
localStorage.setItem('registry.token', bearer.token);
|
||||
localStorage.setItem('registry.issued_at', bearer.issued_at);
|
||||
localStorage.setItem('registry.expires_in', bearer.expires_in);
|
||||
onClose();
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
})
|
||||
req.open('GET', `${realm}?service=${service}&scope=${scope}`)
|
||||
req.setRequestHeader('Authorization', `Basic ${btoa(`${username}:${password}`)}`)
|
||||
req.send()
|
||||
},
|
||||
onKeyUp(event) {
|
||||
// if keyCode is Enter
|
||||
if (event.keyCode === 13) {
|
||||
this.signIn();
|
||||
}
|
||||
},
|
||||
|
||||
}
|
||||
</script>
|
||||
</registry-authentication>
|
|
@ -28,20 +28,23 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|||
<route path="{baseRoute}">
|
||||
<catalog registry-url="{ state.registryUrl }" registry-name="{ state.name }"
|
||||
catalog-elements-limit="{ state.catalogElementsLimit }" on-notify="{ notifySnackbar }"
|
||||
filter-results="{ state.filter }" />
|
||||
filter-results="{ state.filter }" on-authentication="{ onAuthentication }" />
|
||||
</route>
|
||||
<route path="{baseRoute}taglist/(.*)">
|
||||
<tag-list registry-url="{ state.registryUrl }" registry-name="{ state.name }" pull-url="{ state.pullUrl }"
|
||||
image="{ router.getTagListImage() }" show-content-digest="{props.showContentDigest}"
|
||||
is-image-remove-activated="{props.isImageRemoveActivated}" on-notify="{ notifySnackbar }"
|
||||
filter-results="{ state.filter }"></tag-list>
|
||||
filter-results="{ state.filter }" on-authentication="{ onAuthentication }"></tag-list>
|
||||
</route>
|
||||
<route path="{baseRoute}taghistory/(.*)">
|
||||
<tag-history registry-url="{ state.registryUrl }" registry-name="{ state.name }" pull-url="{ state.pullUrl }"
|
||||
image="{ router.getTagHistoryImage() }" tag="{ router.getTagHistoryTag() }"
|
||||
is-image-remove-activated="{props.isImageRemoveActivated}" on-notify="{ notifySnackbar }"></tag-history>
|
||||
is-image-remove-activated="{ props.isImageRemoveActivated }" on-notify="{ notifySnackbar }" on-authentication="{ onAuthentication }"></tag-history>
|
||||
</route>
|
||||
</router>
|
||||
<registry-authentication realm="{ state.realm }" scope="{ state.scope }" service="{ state.service }"
|
||||
on-close="{ onAuthenticationClose }" on-authenticated="{ state.onAuthenticated }"
|
||||
opened="{ state.authenticationDialogOpened }"></registry-authentication>
|
||||
<material-snackbar message="{ state.snackbarMessage }" is-error="{ state.snackbarIsError }"></material-snackbar>
|
||||
</main>
|
||||
<footer>
|
||||
|
@ -70,6 +73,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|||
import TagHistory from './tag-history/tag-history.riot';
|
||||
import DialogsMenu from './dialogs/dialogs-menu.riot';
|
||||
import SearchBar from './search-bar.riot'
|
||||
import RegistryAuthentication from './dialogs/registry-authentication.riot';
|
||||
import {
|
||||
stripHttps,
|
||||
getRegistryServers
|
||||
|
@ -83,6 +87,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|||
TagHistory,
|
||||
DialogsMenu,
|
||||
SearchBar,
|
||||
RegistryAuthentication,
|
||||
Router,
|
||||
Route
|
||||
},
|
||||
|
@ -107,6 +112,30 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|||
snackbarMessage: 'Registry server changed to `' + registryUrl + '`.'
|
||||
})
|
||||
},
|
||||
onAuthentication(opts, onAuthenticated) {
|
||||
const bearer = {
|
||||
token: localStorage.getItem('registry.token'),
|
||||
issued_at: localStorage.getItem('registry.issued_at'),
|
||||
expires_in: localStorage.getItem('registry.expires_in')
|
||||
}
|
||||
if (bearer.token && bearer.issued_at && bearer.expires_in &&
|
||||
(new Date().getTime() - new Date(bearer.issued_at).getTime()) < (bearer.expires_in * 1000)) {
|
||||
onAuthenticated(bearer)
|
||||
} else if (opts) {
|
||||
this.update({
|
||||
authenticationDialogOpened: true,
|
||||
onAuthenticated,
|
||||
...opts
|
||||
})
|
||||
} else {
|
||||
onAuthenticated()
|
||||
}
|
||||
},
|
||||
onAuthenticationClose() {
|
||||
this.update({
|
||||
authenticationDialogOpened: false
|
||||
})
|
||||
},
|
||||
pullUrl(registryUrl, pullUrl) {
|
||||
const url = pullUrl ||
|
||||
(registryUrl && registryUrl.length > 0 && registryUrl) ||
|
||||
|
|
|
@ -50,10 +50,17 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|||
},
|
||||
onBeforeMount(props, state) {
|
||||
state.elements = [];
|
||||
state.image = new DockerImage(props.image, props.tag, true, props.registryUrl, props.onNotify);
|
||||
state.image = new DockerImage(props.image, props.tag, {
|
||||
list: true,
|
||||
registryUrl: props.registryUrl,
|
||||
onNotify: props.onNotify,
|
||||
onAuthentication: props.onAuthentication
|
||||
});
|
||||
state.image.fillInfo()
|
||||
},
|
||||
onMounted(props, state) {
|
||||
state.image.on('blobs', this.processBlobs);
|
||||
state.image.on('list', this.multiArchList)
|
||||
state.image.on('list', this.multiArchList);
|
||||
},
|
||||
onTabChanged(arch, idx) {
|
||||
const state = this.state;
|
||||
|
|
|
@ -82,11 +82,17 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|||
display(props, state) {
|
||||
state.tags = [];
|
||||
const self = this;
|
||||
const oReq = new Http();
|
||||
const oReq = new Http({
|
||||
onAuthentication: props.onAuthentication
|
||||
});
|
||||
oReq.addEventListener('load', function () {
|
||||
if (this.status == 200) {
|
||||
const tags = (JSON.parse(this.responseText).tags || [])
|
||||
.map(tag => new DockerImage(props.image, tag, null, props.registryUrl, props.onNotify))
|
||||
.map(tag => new DockerImage(props.image, tag, {
|
||||
registryUrl: props.registryUrl,
|
||||
onNotify: props.onNotify,
|
||||
onAuthentication: props.onAuthentication
|
||||
}))
|
||||
.sort(compare);
|
||||
window.requestAnimationFrame(self.onResize);
|
||||
self.update({
|
||||
|
|
|
@ -46,13 +46,16 @@ export function compare(e1, e2) {
|
|||
}
|
||||
|
||||
export class DockerImage {
|
||||
constructor(name, tag, list, registryUrl, onNotify) {
|
||||
constructor(name, tag, { list, registryUrl, onNotify, onAuthentication }) {
|
||||
this.name = name;
|
||||
this.tag = tag;
|
||||
this.list = list;
|
||||
this.registryUrl = registryUrl;
|
||||
this.chars = 0;
|
||||
this.onNotify = onNotify;
|
||||
this.opts = {
|
||||
list,
|
||||
registryUrl,
|
||||
onNotify,
|
||||
onAuthentication,
|
||||
};
|
||||
observable(this);
|
||||
this.on('get-size', function () {
|
||||
if (this.size !== undefined) {
|
||||
|
@ -90,7 +93,7 @@ export class DockerImage {
|
|||
return;
|
||||
}
|
||||
this._fillInfoWaiting = true;
|
||||
const oReq = new Http();
|
||||
const oReq = new Http({ onAuthentication: this.opts.onAuthentication });
|
||||
const self = this;
|
||||
oReq.addEventListener('loadend', function () {
|
||||
if (this.status == 200 || this.status == 202) {
|
||||
|
@ -98,7 +101,7 @@ export class DockerImage {
|
|||
if (response.mediaType === 'application/vnd.docker.distribution.manifest.list.v2+json') {
|
||||
self.trigger('list', response);
|
||||
const manifest = response.manifests[0];
|
||||
const image = new DockerImage(self.name, manifest.digest, false, self.registryUrl, self.onNotify);
|
||||
const image = new DockerImage(self.name, manifest.digest, { ...self.opts, list: false });
|
||||
eventTransfer(image, self);
|
||||
image.fillInfo();
|
||||
self.variants = [image];
|
||||
|
@ -115,26 +118,26 @@ export class DockerImage {
|
|||
self.digest = digest;
|
||||
self.trigger('content-digest', digest);
|
||||
if (!digest) {
|
||||
self.onNotify(ERROR_CAN_NOT_READ_CONTENT_DIGEST);
|
||||
self.opts.onNotify(ERROR_CAN_NOT_READ_CONTENT_DIGEST);
|
||||
}
|
||||
});
|
||||
self.getBlobs(response.config.digest);
|
||||
} else if (this.status == 404) {
|
||||
self.onNotify(`Manifest for ${self.name}:${self.tag} not found`, true);
|
||||
self.opts.onNotify(`Manifest for ${self.name}:${self.tag} not found`, true);
|
||||
} else {
|
||||
self.onNotify(this.responseText);
|
||||
self.opts.onNotify(this.responseText);
|
||||
}
|
||||
});
|
||||
oReq.open('GET', this.registryUrl + '/v2/' + self.name + '/manifests/' + self.tag);
|
||||
oReq.open('GET', `${this.opts.registryUrl}/v2/${self.name}/manifests/${self.tag}`);
|
||||
oReq.setRequestHeader(
|
||||
'Accept',
|
||||
'application/vnd.docker.distribution.manifest.v2+json, application/vnd.oci.image.manifest.v1+json' +
|
||||
(self.list ? ', application/vnd.docker.distribution.manifest.list.v2+json' : '')
|
||||
(self.opts.list ? ', application/vnd.docker.distribution.manifest.list.v2+json' : '')
|
||||
);
|
||||
oReq.send();
|
||||
}
|
||||
getBlobs(blob) {
|
||||
const oReq = new Http();
|
||||
const oReq = new Http({ onAuthentication: this.opts.onAuthentication });
|
||||
const self = this;
|
||||
oReq.addEventListener('loadend', function () {
|
||||
if (this.status == 200 || this.status == 202) {
|
||||
|
@ -153,12 +156,12 @@ export class DockerImage {
|
|||
self.trigger('creation-date', self.creationDate);
|
||||
self.trigger('blobs', self.blobs);
|
||||
} else if (this.status == 404) {
|
||||
self.onNotify(`Blobs for ${self.name}:${self.tag} not found`, true);
|
||||
self.opts.onNotify(`Blobs for ${self.name}:${self.tag} not found`, true);
|
||||
} else {
|
||||
self.onNotify(this.responseText);
|
||||
self.opts.onNotify(this.responseText);
|
||||
}
|
||||
});
|
||||
oReq.open('GET', this.registryUrl + '/v2/' + self.name + '/blobs/' + blob);
|
||||
oReq.open('GET', `${this.opts.registryUrl}/v2/${self.name}/blobs/${blob}`);
|
||||
oReq.setRequestHeader(
|
||||
'Accept',
|
||||
'application/vnd.docker.distribution.manifest.v2+json, application/vnd.oci.image.manifest.v1+json'
|
||||
|
|
|
@ -16,12 +16,14 @@
|
|||
*/
|
||||
|
||||
export class Http {
|
||||
constructor() {
|
||||
constructor(opts) {
|
||||
this.oReq = new XMLHttpRequest();
|
||||
this.oReq.hasHeader = hasHeader;
|
||||
this.oReq.getErrorMessage = getErrorMessage;
|
||||
this._events = {};
|
||||
this._headers = {};
|
||||
this.onAuthentication = opts && opts.onAuthentication;
|
||||
this.withCredentials = opts && opts.withCredentials;
|
||||
}
|
||||
|
||||
getContentDigest(cb) {
|
||||
|
@ -34,9 +36,7 @@ export class Http {
|
|||
cb(
|
||||
'sha256:' +
|
||||
Array.from(new Uint8Array(buffer))
|
||||
.map(function (byte) {
|
||||
return byte.toString(16).padStart(2, '0');
|
||||
})
|
||||
.map((byte) => byte.toString(16).padStart(2, '0'))
|
||||
.join('')
|
||||
);
|
||||
});
|
||||
|
@ -52,21 +52,28 @@ export class Http {
|
|||
switch (e) {
|
||||
case 'loadend': {
|
||||
self.oReq.addEventListener('loadend', function () {
|
||||
if (this.status == 401) {
|
||||
const req = new XMLHttpRequest();
|
||||
req._url = self._url;
|
||||
req.open(self._method, self._url);
|
||||
for (key in self._events) {
|
||||
req.addEventListener(key, self._events[key]);
|
||||
}
|
||||
for (key in self._headers) {
|
||||
req.setRequestHeader(key, self._headers[key]);
|
||||
}
|
||||
req.withCredentials = true;
|
||||
req.hasHeader = Http.hasHeader;
|
||||
req.getErrorMessage = Http.getErrorMessage;
|
||||
self.oReq = req;
|
||||
req.send();
|
||||
if (this.status == 401 && !this.withCredentials) {
|
||||
const tokenAuth = parseAuthenticateHeader(this.getResponseHeader('www-authenticate'));
|
||||
self.onAuthentication(tokenAuth, (bearer) => {
|
||||
const req = new XMLHttpRequest();
|
||||
req._url = self._url;
|
||||
req.open(self._method, self._url);
|
||||
for (let key in self._events) {
|
||||
req.addEventListener(key, self._events[key]);
|
||||
}
|
||||
for (let key in self._headers) {
|
||||
req.setRequestHeader(key, self._headers[key]);
|
||||
}
|
||||
if (bearer && bearer.token) {
|
||||
req.setRequestHeader('Authorization', `Bearer ${bearer.token}`)
|
||||
} else {
|
||||
req.withCredentials = true;
|
||||
}
|
||||
req.hasHeader = hasHeader;
|
||||
req.getErrorMessage = Http.getErrorMessage;
|
||||
self.oReq = req;
|
||||
req.send();
|
||||
});
|
||||
} else {
|
||||
f.bind(this)();
|
||||
}
|
||||
|
@ -99,6 +106,9 @@ export class Http {
|
|||
this._method = m;
|
||||
this._url = u;
|
||||
this.oReq._url = u;
|
||||
if (this.withCredentials) {
|
||||
this.oReq.withCredentials = true;
|
||||
}
|
||||
this.oReq.open(m, u);
|
||||
}
|
||||
|
||||
|
@ -139,3 +149,10 @@ const getErrorMessage = function () {
|
|||
'`'
|
||||
);
|
||||
};
|
||||
|
||||
const AUTHENTICATE_HEADER_REGEX = /Bearer realm="(?<realm>[^"]+)",service="(?<service>[^"]+)",scope="(?<scope>[^"]+)"/;
|
||||
|
||||
const parseAuthenticateHeader = (header) => {
|
||||
const exec = AUTHENTICATE_HEADER_REGEX.exec(header);
|
||||
return exec && exec.groups;
|
||||
};
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue