Skip to content

Commit

Permalink
feat(token-auth-keycloak): add token auth via keycloak using docker p…
Browse files Browse the repository at this point in the history
…rotocol
  • Loading branch information
Joxit committed Apr 8, 2021
1 parent 2c9f006 commit 58b1486
Show file tree
Hide file tree
Showing 8 changed files with 180 additions and 44 deletions.
5 changes: 3 additions & 2 deletions examples/token-auth-keycloak/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 3 additions & 1 deletion src/components/catalog/catalog.riot
Original file line number Diff line number Diff line change
Expand Up @@ -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 || [];
Expand Down
71 changes: 71 additions & 0 deletions src/components/dialogs/registry-authentication.riot
Original file line number Diff line number Diff line change
@@ -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>
35 changes: 32 additions & 3 deletions src/components/docker-registry-ui.riot
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down Expand Up @@ -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
Expand All @@ -83,6 +87,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
TagHistory,
DialogsMenu,
SearchBar,
RegistryAuthentication,
Router,
Route
},
Expand All @@ -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) ||
Expand Down
11 changes: 9 additions & 2 deletions src/components/tag-history/tag-history.riot
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
10 changes: 8 additions & 2 deletions src/components/tag-list/tag-list.riot
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
33 changes: 18 additions & 15 deletions src/scripts/docker-image.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -90,15 +93,15 @@ 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) {
const response = JSON.parse(this.responseText);
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];
Expand All @@ -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) {
Expand All @@ -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'
Expand Down
55 changes: 36 additions & 19 deletions src/scripts/http.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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('')
);
});
Expand All @@ -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)();
}
Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -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;
};

0 comments on commit 58b1486

Please sign in to comment.