+
@@ -28,39 +29,79 @@
import Login from '@/components/common/Login.vue'
import EventBus from '@/event-bus'
+const USE_OIDC = import.meta.env.VITE_USE_OIDC === '1'
+
export default {
name: 'App',
- components: {
- Login
- },
+ components: { Login },
data () {
return {
loginModal: false,
- uname: this.$cookies.get('username') || null,
- version: ''
+ uname: null,
+ version: '',
+ useOidc: USE_OIDC,
}
},
methods: {
+ openLogin () {
+ if (!this.useOidc) {
+ this.loginModal = true
+ } else {
+ // Kick off OIDC login if needed
+ if (this.$keycloak && !this.$keycloak.authenticated) {
+ this.$keycloak.login()
+ }
+ }
+ },
loginSuccessful () {
- this.loginModal = false
- this.uname = this.$cookies.get('username')
+ if (!this.useOidc) {
+ this.loginModal = false
+ this.uname = this.$cookies.get('username')
+ } else {
+ this.updateOidcUser()
+ }
if (this.$route.name !== 'home') {
this.$router.push({ name: 'home' })
}
},
+ updateOidcUser () {
+ if (this.$keycloak && this.$keycloak.tokenParsed) {
+ this.uname = this.$keycloak.tokenParsed.preferred_username || null
+ this.$http.defaults.headers.common['Authorization'] = `Bearer ${this.$keycloak.token}`
+ }
+ },
logout () {
+ this.uname = null
this.$cookies.remove('username')
this.$cookies.remove('api-token')
this.$cookies.remove('admin')
- if (this.$route.name !== 'home') {
- this.$router.push({ name: 'home' })
+
+ if (this.useOidc && this.$keycloak && this.$keycloak.authenticated) {
+ this.$keycloak.logout({
+ redirectUri: import.meta.env.VITE_LOGOUT_REDIRECT_URI || 'http://home.cogstack.localhost/'
+ })
} else {
- this.$router.go()
+ if (this.$route.name !== 'home') {
+ this.$router.push({name: 'home'})
+ } else {
+ this.$router.go()
+ }
}
}
},
mounted () {
EventBus.$on('login:success', this.loginSuccessful)
+
+ if (!this.useOidc) {
+ this.uname = this.$cookies.get('username') || null
+ } else {
+ if (this.$keycloak && this.$keycloak.authenticated) {
+ this.updateOidcUser()
+ // Watch for token refresh events
+ this.$keycloak.onAuthRefreshSuccess = () => this.updateOidcUser()
+ this.$keycloak.onAuthSuccess = () => this.updateOidcUser()
+ }
+ }
},
beforeDestroy () {
EventBus.$off('login:success', this.loginSuccessful)
diff --git a/medcat-trainer/webapp/frontend/src/auth.ts b/medcat-trainer/webapp/frontend/src/auth.ts
new file mode 100644
index 000000000..84b2daecf
--- /dev/null
+++ b/medcat-trainer/webapp/frontend/src/auth.ts
@@ -0,0 +1,50 @@
+import type { App } from 'vue'
+import Keycloak, { KeycloakConfig } from 'keycloak-js'
+import axios from 'axios'
+
+let keycloak: Keycloak
+
+export const authPlugin = {
+ install: async (app: App) => {
+ const config: KeycloakConfig = {
+ url: import.meta.env.VITE_KEYCLOAK_URL,
+ realm: import.meta.env.VITE_KEYCLOAK_REALM,
+ clientId: import.meta.env.VITE_KEYCLOAK_CLIENT_ID,
+ }
+
+ keycloak = new Keycloak(config)
+
+ const authenticated = await keycloak.init({
+ onLoad: 'login-required',
+ pkceMethod: 'S256',
+ checkLoginIframe: false,
+ })
+
+ if (!authenticated) {
+ console.warn('User is not authenticated')
+ window.location.reload()
+ }
+
+ // configure axios
+ axios.defaults.headers.common['Authorization'] = `Bearer ${keycloak.token}`
+
+ const refreshInterval = Number(import.meta.env.VITE_KEYCLOAK_TOKEN_REFRESH_INTERVAL) || 10000
+ const minValidity = Number(import.meta.env.VITE_KEYCLOAK_TOKEN_MIN_VALIDITY) || 30
+
+ setInterval(() => {
+ keycloak.updateToken(minValidity)
+ .then(refreshed => {
+ if (refreshed) {
+ axios.defaults.headers.common['Authorization'] = `Bearer ${keycloak.token}`
+ }
+ })
+ .catch(err => {
+ console.error('Failed to refresh token', err)
+ })
+ }, refreshInterval)
+
+
+ app.config.globalProperties.$keycloak = keycloak
+ app.config.globalProperties.$http = axios
+ },
+}
diff --git a/medcat-trainer/webapp/frontend/src/components/common/Login.vue b/medcat-trainer/webapp/frontend/src/components/common/Login.vue
index eac0887eb..087741d29 100644
--- a/medcat-trainer/webapp/frontend/src/components/common/Login.vue
+++ b/medcat-trainer/webapp/frontend/src/components/common/Login.vue
@@ -1,23 +1,25 @@
-
-
- Login
-
-
-
- Username and/or password incorrect
- Cannot determine admin status of username
-
-
-
-
-
+
+
+
+ Login
+
+
+
+ Username and/or password incorrect
+ Cannot determine admin status of username
+
+
+
+
+
+
diff --git a/medcat-trainer/webapp/frontend/src/main.ts b/medcat-trainer/webapp/frontend/src/main.ts
index bb37fc2e3..c38454084 100644
--- a/medcat-trainer/webapp/frontend/src/main.ts
+++ b/medcat-trainer/webapp/frontend/src/main.ts
@@ -22,6 +22,7 @@ import 'vuetify/styles'
import { createVuetify } from 'vuetify'
import * as components from 'vuetify/components'
import * as directives from 'vuetify/directives'
+import {authPlugin} from "./auth";
const theme ={
dark: false,
@@ -45,25 +46,38 @@ const vuetify = createVuetify({
}
})
-const app = createApp(App)
-app.config.globalProperties.$http = axios
-app.component("v-select", vSelect)
-app.component('vue-simple-context-menu', VueSimpleContextMenu)
-app.component('font-awesome-icon', FontAwesomeIcon)
-app.use(router)
-app.use(VueCookies, { expires: '7d'})
-app.use(vuetify);
+const USE_OIDC = import.meta.env.VITE_USE_OIDC === '1'
-(function () {
- const apiToken = document.cookie
- .split(';')
- .map(s => s.trim().split('='))
- .filter(s => s[0] === 'api-token')
- if (apiToken.length) {
- axios.defaults.headers.common['Authorization'] = `Token ${apiToken[0][1]}`
- axios.defaults.timeout = 6000000000
+async function bootstrap() {
+ const app = createApp(App)
+ app.config.globalProperties.$http = axios
+ app.component("v-select", vSelect)
+ app.component('vue-simple-context-menu', VueSimpleContextMenu)
+ app.component('font-awesome-icon', FontAwesomeIcon)
+ app.use(router)
+ app.use(VueCookies, { expires: '7d'})
+ app.use(vuetify);
+
+ console.log("Running in " + import.meta.env.MODE)
+
+ if (USE_OIDC) {
+ await authPlugin.install(app)
+ } else {
+ const apiToken = document.cookie
+ .split(';')
+ .map(s => s.trim().split('='))
+ .filter(s => s[0] === 'api-token')
+
+ if (apiToken.length) {
+ axios.defaults.headers.common['Authorization'] = `Token ${apiToken[0][1]}`
+ axios.defaults.timeout = 6000000000
+ }
+
+ app.config.globalProperties.$http = axios
}
-})()
-app.config.compilerOptions.whitespace = 'preserve';
-app.mount('#app')
+ app.config.compilerOptions.whitespace = 'preserve'
+ app.mount('#app')
+}
+
+bootstrap()
diff --git a/medcat-trainer/webapp/frontend/src/tests/App.spec.ts b/medcat-trainer/webapp/frontend/src/tests/App.spec.ts
index 0d26ddee3..f14156b62 100644
--- a/medcat-trainer/webapp/frontend/src/tests/App.spec.ts
+++ b/medcat-trainer/webapp/frontend/src/tests/App.spec.ts
@@ -57,7 +57,7 @@ describe('App.vue', () => {
expect(links[4].props('to')).toBe('/demo')
})
-
+
it('calls /api/version/ when created', async () => {
const mockGet = vi.fn().mockResolvedValue({ data: 'v1.2.3' });
mount(App, {
@@ -92,7 +92,7 @@ describe('App.vue', () => {
},
data() {
return {
- uname: 'testuser',
+ uname: 'testUser',
loginModal: false,
version: 'v1.2.3'
}
@@ -101,7 +101,7 @@ describe('App.vue', () => {
await router.isReady()
await flushPromises()
- expect(wrapper.text()).toContain('testuser');
- expect(wrapper.find('.logout').exists()).toBe(true);
+ expect(wrapper.text()).toContain('testUser');
+ expect(wrapper.find('.logout').exists()).toBe(true);
});
- });
\ No newline at end of file
+ });
diff --git a/medcat-trainer/webapp/frontend/src/views/Home.vue b/medcat-trainer/webapp/frontend/src/views/Home.vue
index 9d5a720cb..e187acacd 100644
--- a/medcat-trainer/webapp/frontend/src/views/Home.vue
+++ b/medcat-trainer/webapp/frontend/src/views/Home.vue
@@ -46,7 +46,6 @@ import Login from '@/components/common/Login.vue'
import EventBus from '@/event-bus'
import ProjectList from "@/components/common/ProjectList.vue"
-
export default {
name: 'Home',
components: {
@@ -73,7 +72,8 @@ export default {
loadingProjects: false,
isAdmin: false,
selectedProjectGroup: null,
- cdbSearchIndexStatus: {}
+ cdbSearchIndexStatus: {},
+ useOidc: import.meta.env.VITE_USE_OIDC === '1'
}
},
created () {
@@ -101,11 +101,15 @@ export default {
})
// assume if there's an api-token we've logged in before and will try get projects
// fallback to logging in otherwise
- if (this.$cookies.get('api-token')) {
- this.loginSuccessful = true
- this.isAdmin = this.$cookies.get('admin') === 'true'
- this.fetchProjects()
- }
+ if (!this.useOidc && this.$cookies.get('api-token')) {
+ this.loginSuccessful = true
+ this.isAdmin = this.$cookies.get('admin') === 'true'
+ this.fetchProjects()
+ } else if (this.useOidc && this.$keycloak && this.$keycloak.authenticated) {
+ this.loginSuccessful = true
+ this.isAdmin = this.$keycloak.tokenParsed?.realm_access?.roles.includes('admin') ?? false;
+ this.fetchProjects()
+ }
},
fetchProjectGroups () {
const projectGroupIds = new Set(this.projects.items.filter(p => p.group !== null).map(p => p.group))
diff --git a/medcat-trainer/webapp/frontend/tsconfig.json b/medcat-trainer/webapp/frontend/tsconfig.json
index 100cf6a8f..2d3638f3b 100644
--- a/medcat-trainer/webapp/frontend/tsconfig.json
+++ b/medcat-trainer/webapp/frontend/tsconfig.json
@@ -10,5 +10,9 @@
{
"path": "./tsconfig.vitest.json"
}
- ]
+ ],
+ "compilerOptions": {
+ "target": "esnext",
+ "module": "esnext"
+ }
}
diff --git a/medcat-trainer/webapp/frontend/vite.config.ts b/medcat-trainer/webapp/frontend/vite.config.ts
index 77eb8ea7c..fba0599d6 100644
--- a/medcat-trainer/webapp/frontend/vite.config.ts
+++ b/medcat-trainer/webapp/frontend/vite.config.ts
@@ -31,7 +31,7 @@ export default defineConfig({
},
'^/api/*': {
target: 'http://127.0.0.1:8001'
- }
+ }
}
},
css: {
diff --git a/medcat-trainer/webapp/requirements.txt b/medcat-trainer/webapp/requirements.txt
index 46b494f77..e972bcef1 100644
--- a/medcat-trainer/webapp/requirements.txt
+++ b/medcat-trainer/webapp/requirements.txt
@@ -8,4 +8,6 @@ django-background-tasks-updated==1.2.*
openpyxl==3.1.2
medcat[meta-cat,spacy,rel-cat,deid]==2.1.*
psycopg[binary,pool]==3.2.9
+cryptography==45.0.*
+drf-oidc-auth==3.0.0
django-health-check==3.20.0
diff --git a/medcat-trainer/webapp/scripts/load_examples.py b/medcat-trainer/webapp/scripts/load_examples.py
index fd011093d..2968039e8 100644
--- a/medcat-trainer/webapp/scripts/load_examples.py
+++ b/medcat-trainer/webapp/scripts/load_examples.py
@@ -5,12 +5,34 @@
from time import sleep
import json
+def get_keycloak_access_token():
+ print('Getting Keycloak access token...')
+ keycloak_url = os.environ.get("KEYCLOAK_URL", "http://keycloak.cogstack.localhost")
+ realm = os.environ.get("KEYCLOAK_REALM", "cogstack-realm")
+ client_id = os.environ.get("KEYCLOAK_CLIENT_ID", "cogstack-medcattrainer-frontend")
+ username = os.environ.get("KEYCLOAK_USERNAME", "admin")
+ password = os.environ.get("KEYCLOAK_PASSWORD", "admin")
+
+ token_url = f"{keycloak_url}/realms/{realm}/protocol/openid-connect/token"
+
+ data = {
+ "grant_type": "password",
+ "client_id": client_id,
+ "username": username,
+ "password": password,
+ "scope": "openid profile email"
+ }
+
+ resp = requests.post(token_url, data=data)
+ resp.raise_for_status()
+ return resp.json()["access_token"]
def main(port=8000,
model_pack_tmp_file='/home/model_pack.zip',
dataset_tmp_file='/home/ds.csv',
initial_wait=15):
+ print('Checking for environment variable LOAD_EXAMPLES...')
val = os.environ.get('LOAD_EXAMPLES')
if val is not None and val not in ('1', 'true', 't', 'y'):
print('Found Env Var LOAD_EXAMPLES is False, not loading example data, cdb, vocab and project')
@@ -25,15 +47,26 @@ def main(port=8000,
try:
# check API is available
if requests.get(URL).status_code == 200:
- # check API default username and pass are available.
- payload = {"username": "admin", "password": "admin"}
- resp = requests.post(f"{URL}api-token-auth/", json=payload)
- if resp.status_code != 200:
- break
- headers = {
- 'Authorization': f'Token {json.loads(resp.text)["token"]}',
- }
+ use_oidc = os.environ.get('USE_OIDC')
+ print('Checking for environment variable USE_OIDC...')
+ if use_oidc is not None and use_oidc in ('1', 'true', 't', 'y'):
+ print('Found environment variable USE_OIDC is set to truthy value. Will load data using JWT')
+ token = get_keycloak_access_token()
+ headers = {
+ 'Authorization': f'Bearer {token}',
+ }
+ else:
+ # check API default username and pass are available.
+ print('Getting DRF auth token ...')
+ payload = {"username": "admin", "password": "admin"}
+ resp = requests.post(f"{URL}api-token-auth/", json=payload)
+ if resp.status_code != 200:
+ break
+
+ headers = {
+ 'Authorization': f'Token {json.loads(resp.text)["token"]}',
+ }
# check concepts DB, vocab, datasets and projects are empty
resp_model_packs = requests.get(f'{URL}modelpacks/', headers=headers)
diff --git a/medcat-trainer/webapp/scripts/run.sh b/medcat-trainer/webapp/scripts/run.sh
index 7e94b228f..7197df010 100755
--- a/medcat-trainer/webapp/scripts/run.sh
+++ b/medcat-trainer/webapp/scripts/run.sh
@@ -18,7 +18,7 @@ python /home/api/manage.py migrate api --noinput
# also create a user group `user_group` that prevents users from deleting models
echo "from django.contrib.auth import get_user_model
User = get_user_model()
-if User.objects.count() == 0:
+if not User.objects.filter(username='admin').exists():
User.objects.create_superuser('admin', 'admin@example.com', 'admin')
" | python manage.py shell