From c891315f9730579da32959471b27b2468e5c6077 Mon Sep 17 00:00:00 2001 From: Xiao Gui <xgui3783@gmail.com> Date: Fri, 15 Mar 2019 15:34:57 +0100 Subject: [PATCH] feat: added oidc login --- deploy/auth/hbp-oidc.js | 38 +++++++++++ deploy/auth/index.js | 36 ++++++++++ deploy/auth/oidc.js | 39 +++++++++++ deploy/catchError.js | 2 +- deploy/datasets/index.js | 9 ++- deploy/datasets/query.js | 46 ++++++++----- deploy/package.json | 3 + deploy/server.js | 66 +++++++++++++++---- src/atlasViewer/atlasViewer.component.ts | 1 + .../pluginUnit/pluginUnit.component.ts | 3 +- .../pagination/pagination.component.ts | 3 +- .../pagination/pagination.style.css | 6 -- src/css/darkBtns.css | 12 ++++ src/main.module.ts | 8 +++ src/res/css/extra_styles.css | 21 ++++++ src/services/auth.service.ts | 52 +++++++++++++++ src/ui/banner/banner.component.ts | 24 ++++++- src/ui/banner/banner.style.css | 2 +- src/ui/banner/banner.template.html | 45 +++++++++++++ src/ui/pluginBanner/pluginBanner.component.ts | 5 +- src/ui/pluginBanner/pluginBanner.style.css | 6 -- src/ui/ui.module.ts | 2 + 22 files changed, 375 insertions(+), 54 deletions(-) create mode 100644 deploy/auth/hbp-oidc.js create mode 100644 deploy/auth/index.js create mode 100644 deploy/auth/oidc.js create mode 100644 src/css/darkBtns.css create mode 100644 src/services/auth.service.ts diff --git a/deploy/auth/hbp-oidc.js b/deploy/auth/hbp-oidc.js new file mode 100644 index 000000000..ab8ec12b7 --- /dev/null +++ b/deploy/auth/hbp-oidc.js @@ -0,0 +1,38 @@ +const passport = require('passport') +const { configureAuth } = require('./oidc') + +const HOSTNAME = process.env.HOSTNAME || 'http://localhost:3000' +const clientId = process.env.HBP_CLIENTID || 'no hbp id' +const clientSecret = process.env.HBP_CLIENTSECRET || 'no hbp client secret' +const discoveryUrl = 'https://services.humanbrainproject.eu/oidc' +const redirectUri = `${HOSTNAME}/hbp-oidc/cb` +const cb = (tokenset, {sub, given_name, family_name, ...rest}, done) => { + return done(null, { + id: `hbp-oidc:${sub}`, + name: `${given_name} ${family_name}`, + type: `hbp-oidc`, + tokenset, + rest + }) +} + +module.exports = async (app) => { + const { oidcStrategy } = await configureAuth({ + clientId, + clientSecret, + discoveryUrl, + redirectUri, + cb, + clientConfig: { + redirect_uris: [ redirectUri ], + response_types: [ 'code' ] + } + }) + + passport.use('hbp-oidc', oidcStrategy) + app.get('/hbp-oidc/auth', passport.authenticate('hbp-oidc')) + app.get('/hbp-oidc/cb', passport.authenticate('hbp-oidc', { + successRedirect: '/', + failureRedirect: '/' + })) +} diff --git a/deploy/auth/index.js b/deploy/auth/index.js new file mode 100644 index 000000000..f76f6cc42 --- /dev/null +++ b/deploy/auth/index.js @@ -0,0 +1,36 @@ +const hbpOidc = require('./hbp-oidc') +const passport = require('passport') +const objStoreDb = new Map() + +module.exports = async (app) => { + app.use(passport.initialize()) + app.use(passport.session()) + + passport.serializeUser((user, done) => { + objStoreDb.set(user.id, user) + done(null, user.id) + }) + + passport.deserializeUser((id, done) => { + const user = objStoreDb.get(id) + if (user) + return done(null, user) + else + return done(null, false) + }) + + await hbpOidc(app) + + app.get('/user', (req, res) => { + if (req.user) { + return res.status(200).send(JSON.stringify(req.user)) + } else { + return res.sendStatus(401) + } + }) + + app.get('/logout', (req, res) => { + req.logout() + res.redirect('/') + }) +} \ No newline at end of file diff --git a/deploy/auth/oidc.js b/deploy/auth/oidc.js new file mode 100644 index 000000000..856adc48b --- /dev/null +++ b/deploy/auth/oidc.js @@ -0,0 +1,39 @@ +const { Issuer, Strategy } = require('openid-client') + +const defaultCb = (tokenset, {id, ...rest}, done) => { + return done(null, { + id: id || Date.now(), + ...rest + }) +} + +exports.configureAuth = async ({ discoveryUrl, clientId, clientSecret, redirectUri, clientConfig = {}, cb = defaultCb }) => { + if (!discoveryUrl) + throw new Error('discoveryUrl must be defined!') + + if (!clientId) + throw new Error('clientId must be defined!') + + if (!clientSecret) + throw new Error('clientSecret must be defined!') + + if (!redirectUri) + throw new Error('redirectUri must be defined!') + + const issuer = await Issuer.discover(discoveryUrl) + + const client = new issuer.Client({ + client_id: clientId, + client_secret: clientSecret, + ...clientConfig + }) + + const oidcStrategy = new Strategy({ + client, + redirect_uri: redirectUri + }, cb) + + return { + oidcStrategy + } +} \ No newline at end of file diff --git a/deploy/catchError.js b/deploy/catchError.js index f1e385c31..cf7c96612 100644 --- a/deploy/catchError.js +++ b/deploy/catchError.js @@ -2,5 +2,5 @@ module.exports = ({code = 500, error = 'an error had occured'}, req, res, next) /** * probably use more elaborate logging? */ - res.status(code).send(error) + res.sendStatus(code) } \ No newline at end of file diff --git a/deploy/datasets/index.js b/deploy/datasets/index.js index 1d9ac9c01..10eb068c5 100644 --- a/deploy/datasets/index.js +++ b/deploy/datasets/index.js @@ -2,7 +2,9 @@ const express = require('express') const datasetsRouter = express.Router() const { init, getDatasets } = require('./query') -init() +init().catch(e => { + console.warn(`dataset init failed`, e) +}) datasetsRouter.get('/templateName/:templateName', (req, res, next) => { const { templateName } = req.params @@ -20,9 +22,10 @@ datasetsRouter.get('/templateName/:templateName', (req, res, next) => { -datasetsRouter.get('/parcellationName/:parcellationName', (req, res) => { +datasetsRouter.get('/parcellationName/:parcellationName', (req, res, next) => { const { parcellationName } = req.params - getDatasets({ parcellationName }) + const { user } = req + getDatasets({ parcellationName, user }) .then(ds => { res.status(200).send(JSON.stringify(ds)) }) diff --git a/deploy/datasets/query.js b/deploy/datasets/query.js index 816603ae5..192be5953 100644 --- a/deploy/datasets/query.js +++ b/deploy/datasets/query.js @@ -4,23 +4,35 @@ const path = require('path') let cachedData = null let otherQueryResult = null -const queryUrl = process.env.KG_DATASET_QUERY_URL || `https://kg-int.humanbrainproject.org/query/minds/core/dataset/v1.0.0/interactiveViewerKgQuery/instances/public?size=450&vocab=https%3A%2F%2Fschema.hbp.eu%2FmyQuery%2F` +const queryUrl = process.env.KG_DATASET_QUERY_URL || `https://kg-int.humanbrainproject.org/query/minds/core/dataset/v1.0.0/interactiveViewerKgQuery/instances?size=450&vocab=https%3A%2F%2Fschema.hbp.eu%2FmyQuery%2F` const timeout = process.env.TIMEOUT || 5000 -const fetchDatasetFromKg = () => new Promise((resolve, reject) => { - request(queryUrl, (err, resp, body) => { +const fetchDatasetFromKg = (arg) => new Promise((resolve, reject) => { + const accessToken = arg && arg.user && arg.user.tokenset && arg.user.tokenset.access_token + const option = accessToken + ? { + auth: { + 'bearer': accessToken + } + } + : {} + request(queryUrl, option, (err, resp, body) => { if (err) return reject(err) - try { - const json = JSON.parse(body) - return resolve(json) - } catch (e) { - return reject(e) - } + if (resp.statusCode >= 400) + return reject(resp.statusCode) + const json = JSON.parse(body) + return resolve(json) }) }) -const getDs = () => Promise.race([ +const cacheData = ({results, ...rest}) => { + cachedData = results + otherQueryResult = rest + return cachedData +} + +const getPublicDs = () => Promise.race([ new Promise((rs, rj) => { setTimeout(() => { if (cachedData) { @@ -32,14 +44,14 @@ const getDs = () => Promise.race([ } }, timeout) }), - fetchDatasetFromKg() - .then(({results, ...rest}) => { - cachedData = results - otherQueryResult = rest - return cachedData - }) + fetchDatasetFromKg().then(cacheData) ]) + +const getDs = ({ user }) => user + ? fetchDatasetFromKg({ user }).then(({results}) => results) + : getPublicDs() + /** * Needed by filter by parcellation */ @@ -116,7 +128,7 @@ exports.init = () => fetchDatasetFromKg() cachedData = json }) -exports.getDatasets = ({ templateName, parcellationName }) => getDs() +exports.getDatasets = ({ templateName, parcellationName, user }) => getDs({ user }) .then(json => filter(json, {templateName, parcellationName})) diff --git a/deploy/package.json b/deploy/package.json index 0dfc57457..5916cb005 100644 --- a/deploy/package.json +++ b/deploy/package.json @@ -11,7 +11,10 @@ "license": "ISC", "dependencies": { "express": "^4.16.4", + "express-session": "^1.15.6", + "memorystore": "^1.6.1", "openid-client": "^2.4.5", + "passport": "^0.4.0", "request": "^2.88.0" }, "devDependencies": { diff --git a/deploy/server.js b/deploy/server.js index d740f1487..8f3a55817 100644 --- a/deploy/server.js +++ b/deploy/server.js @@ -1,31 +1,69 @@ const path = require('path') const express = require('express') const app = express() +const session = require('express-session') +const MemoryStore = require('memorystore')(session) + app.disable('x-powered-by') if (process.env.NODE_ENV !== 'production') { require('dotenv').config() app.use(require('cors')()) + process.on('unhandledRejection', (err, p) => { + console.log({err, p}) + }) } -const templateRouter = require('./templates') -const nehubaConfigRouter = require('./nehubaConfig') -const datasetRouter = require('./datasets') -const catchError = require('./catchError') +/** + * load env first, then load other modules + */ + +const configureAuth = require('./auth') -const publicPath = process.env.NODE_ENV === 'production' - ? path.join(__dirname, 'public') - : path.join(__dirname, '..', 'dist', 'aot') +const store = new MemoryStore({ + checkPeriod: 86400000 +}) -app.use('/templates', templateRouter) -app.use('/nehubaConfig', nehubaConfigRouter) -app.use('/datasets', datasetRouter) +const SESSIONSECRET = process.env.SESSIONSECRET || 'this is not really a random session secret' -app.use(catchError) +/** + * passport application of oidc requires session + */ +app.use(session({ + secret: SESSIONSECRET, + resave: true, + saveUninitialized: false, + store +})) -app.use(express.static(publicPath)) +const startServer = async (app) => { + try{ + await configureAuth(app) + }catch (e) { + console.log('error during configureAuth', e) + } -const PORT = process.env.PORT || 3000 + const templateRouter = require('./templates') + const nehubaConfigRouter = require('./nehubaConfig') + const datasetRouter = require('./datasets') + const catchError = require('./catchError') + + const publicPath = process.env.NODE_ENV === 'production' + ? path.join(__dirname, 'public') + : path.join(__dirname, '..', 'dist', 'aot') + + app.use('/templates', templateRouter) + app.use('/nehubaConfig', nehubaConfigRouter) + app.use('/datasets', datasetRouter) + + app.use(catchError) + + app.use(express.static(publicPath)) + + const PORT = process.env.PORT || 3000 + + app.listen(PORT, () => console.log(`listening on port ${PORT}`)) +} -app.listen(PORT, () => console.log(`listening on port ${PORT}`)) \ No newline at end of file +startServer(app) \ No newline at end of file diff --git a/src/atlasViewer/atlasViewer.component.ts b/src/atlasViewer/atlasViewer.component.ts index 014c570dc..2211af270 100644 --- a/src/atlasViewer/atlasViewer.component.ts +++ b/src/atlasViewer/atlasViewer.component.ts @@ -34,6 +34,7 @@ export class AtlasViewer implements OnDestroy, OnInit { @ViewChild('floatingMouseContextualContainer', { read: ViewContainerRef }) floatingMouseContextualContainer: ViewContainerRef @ViewChild('helpComponent', {read: TemplateRef}) helpComponent : TemplateRef<any> @ViewChild('viewerConfigComponent', {read: TemplateRef}) viewerConfigComponent : TemplateRef<any> + @ViewChild('loginComponent', {read: TemplateRef}) loginComponent: TemplateRef <any> @ViewChild(LayoutMainSide) layoutMainSide: LayoutMainSide @ViewChild(NehubaContainer) nehubaContainer: NehubaContainer diff --git a/src/atlasViewer/pluginUnit/pluginUnit.component.ts b/src/atlasViewer/pluginUnit/pluginUnit.component.ts index 8afdef862..5d849eab7 100644 --- a/src/atlasViewer/pluginUnit/pluginUnit.component.ts +++ b/src/atlasViewer/pluginUnit/pluginUnit.component.ts @@ -17,7 +17,8 @@ export class PluginUnit implements OnDestroy{ } ngOnDestroy(){ - console.log('plugin being destroyed') + if (!PRODUCTION) + console.log('plugin being destroyed') } } \ No newline at end of file diff --git a/src/components/pagination/pagination.component.ts b/src/components/pagination/pagination.component.ts index 1723d4540..0e5ff89ee 100644 --- a/src/components/pagination/pagination.component.ts +++ b/src/components/pagination/pagination.component.ts @@ -4,7 +4,8 @@ import { Component, Input, Output, EventEmitter } from '@angular/core' selector : 'pagination-component', templateUrl : './pagination.template.html', styleUrls : [ - './pagination.style.css' + './pagination.style.css', + '../../css/darkBtns.css' ] }) diff --git a/src/components/pagination/pagination.style.css b/src/components/pagination/pagination.style.css index 57325bbb9..ca46999f9 100644 --- a/src/components/pagination/pagination.style.css +++ b/src/components/pagination/pagination.style.css @@ -44,12 +44,6 @@ div.btn:hover:before opacity : 0.3; } -:host-context([darktheme="true"]) div.btn.btn-default -{ - background-color:rgba(128,128,128,0.5); - color:rgba(240,240,240,0.9); -} - div.btn.btn-primary { transform:translateY(3%); diff --git a/src/css/darkBtns.css b/src/css/darkBtns.css new file mode 100644 index 000000000..35e4e24ea --- /dev/null +++ b/src/css/darkBtns.css @@ -0,0 +1,12 @@ + +:host-context([darktheme="true"]) .btn.btn-default +{ + background-color:rgba(128,128,128,0.5); + color:rgba(240,240,240,0.9); + border: none; +} + +:host-context([darktheme="true"]) .btn.btn-default > * +{ + font-size: 100%; +} \ No newline at end of file diff --git a/src/main.module.ts b/src/main.module.ts index 911962ffb..94d90d087 100644 --- a/src/main.module.ts +++ b/src/main.module.ts @@ -32,6 +32,7 @@ import { DockedContainerDirective } from "./util/directives/dockedContainer.dire import { FloatingContainerDirective } from "./util/directives/floatingContainer.directive"; import { PluginFactoryDirective } from "./util/directives/pluginFactory.directive"; import { FloatingMouseContextualContainerDirective } from "./util/directives/floatingMouseContextualContainer.directive"; +import { AuthService } from "./services/auth.service"; @NgModule({ imports : [ @@ -94,6 +95,7 @@ import { FloatingMouseContextualContainerDirective } from "./util/directives/flo AtlasViewerAPIServices, ToastService, AtlasWorkerService, + AuthService ], bootstrap : [ AtlasViewer @@ -101,4 +103,10 @@ import { FloatingMouseContextualContainerDirective } from "./util/directives/flo }) export class MainModule{ + + constructor( + authServce: AuthService + ){ + authServce.authReloadState() + } } \ No newline at end of file diff --git a/src/res/css/extra_styles.css b/src/res/css/extra_styles.css index 96f68781b..5481f94d0 100644 --- a/src/res/css/extra_styles.css +++ b/src/res/css/extra_styles.css @@ -244,4 +244,25 @@ markdown-dom pre code { background-color: rgba(70, 70 , 70, 1.0); color: rgba(255, 255, 255, 1.0); +} + +.popoverContainer .popover-header +{ + border: none; +} + +[darktheme="true"] .popoverContainer.popover +{ + background-color: rgba(50,50,50); + color: white; +} + +[darktheme="true"] .popoverContainer.popover .popover-header +{ + background-color: rgba(75,75,75); +} + +[darktheme="true"] .popoverContainer.popover.bottom .popover-arrow.arrow::after +{ + border-bottom-color: rgb(75,75,75); } \ No newline at end of file diff --git a/src/services/auth.service.ts b/src/services/auth.service.ts new file mode 100644 index 000000000..20a8f38d1 --- /dev/null +++ b/src/services/auth.service.ts @@ -0,0 +1,52 @@ +import { Injectable } from "@angular/core"; + +const IV_REDIRECT_TOKEN = `IV_REDIRECT_TOKEN` + +@Injectable({ + providedIn: 'root' +}) + +export class AuthService{ + public user: User | null + + public logoutHref: String = 'logout' + + /** + * TODO build it dynamically, or at least possible to configure via env var + */ + public loginMethods : AuthMethod[] = [{ + name: 'HBP OIDC', + href: 'hbp-oidc/auth' + }] + + constructor() { + fetch('user') + .then(res => res.json()) + .then(user => this.user = user) + .catch(e => { + if (!PRODUCTION) + console.log(`auth failed`, e) + }) + } + + authSaveState() { + window.localStorage.setItem(IV_REDIRECT_TOKEN, window.location.href) + } + + authReloadState() { + + const redirect = window.localStorage.getItem(IV_REDIRECT_TOKEN) + window.localStorage.removeItem(IV_REDIRECT_TOKEN) + if (redirect) window.location.href = redirect + } +} + +export interface User { + name: String + id: String +} + +export interface AuthMethod{ + href: String + name: String +} diff --git a/src/ui/banner/banner.component.ts b/src/ui/banner/banner.component.ts index fde833e0f..f69c931ab 100644 --- a/src/ui/banner/banner.component.ts +++ b/src/ui/banner/banner.component.ts @@ -6,12 +6,14 @@ import { map, filter, debounceTime, buffer, distinctUntilChanged } from "rxjs/op import { FilterNameBySearch } from "../../util/pipes/filterNameBySearch.pipe"; import { regionAnimation } from "./regionPopover.animation"; import { AtlasViewerConstantsServices } from "../../atlasViewer/atlasViewer.constantService.service" +import { AuthService, User, AuthMethod } from "src/services/auth.service"; @Component({ selector: 'atlas-banner', templateUrl: './banner.template.html', styleUrls: [ - `./banner.style.css` + `./banner.style.css`, + '../../css/darkBtns.css' ], animations: [ regionAnimation @@ -42,7 +44,8 @@ export class AtlasBanner implements OnDestroy, OnInit { constructor( private store: Store<ViewerStateInterface>, - private constantService: AtlasViewerConstantsServices + private constantService: AtlasViewerConstantsServices, + private authService: AuthService ) { this.loadedTemplates$ = this.store.pipe( select('viewerState'), @@ -139,6 +142,18 @@ export class AtlasBanner implements OnDestroy, OnInit { this.regionsLabelIndexMap = getLabelIndexMap(parcellation.regions) } + get user() : User | null { + return this.authService.user + } + + get loginMethods(): AuthMethod[] { + return this.authService.loginMethods + } + + get logoutHref(): String { + return this.authService.logoutHref + } + selectTemplate(template: any) { if (this.selectedTemplate === template) { return @@ -292,6 +307,11 @@ export class AtlasBanner implements OnDestroy, OnInit { this.constantService.showConfigSubject$.next() } + loginBtnOnclick() { + this.authService.authSaveState() + return true + } + get toastDuration() { return this.constantService.citationToastDuration } diff --git a/src/ui/banner/banner.style.css b/src/ui/banner/banner.style.css index 407d7383f..4a47b703b 100644 --- a/src/ui/banner/banner.style.css +++ b/src/ui/banner/banner.style.css @@ -106,4 +106,4 @@ citations-component .help-container { margin:1em; -} \ No newline at end of file +} diff --git a/src/ui/banner/banner.template.html b/src/ui/banner/banner.template.html index 7028ad640..92118772e 100644 --- a/src/ui/banner/banner.template.html +++ b/src/ui/banner/banner.template.html @@ -96,3 +96,48 @@ <i (click)="showConfig()" class="glyphicon glyphicon-cog"></i> </div> <i *ngIf="!isMobile" (click)="showConfig()" class="glyphicon glyphicon-cog"></i> + +<!-- login btn --> +<div *ngIf="isMobile" class="help-container"> + <i + placement="auto" + triggers="click" + [outsideClick]="true" + [popover]="loginPopover" + [popoverTitle]="user ? 'Hi, ' + user.name : 'Login'" + [ngClass]="user ? 'glyphicon-user' : 'glyphicon-log-in'" + class="glyphicon" + containerClass="popoverContainer"></i> +</div> +<i + *ngIf="!isMobile" + placement="auto" + triggers="click" + [outsideClick]="true" + [popover]="loginPopover" + [popoverTitle]="user ? 'Hi, ' + user.name : 'Login with'" + [ngClass]="user ? 'glyphicon-user' : 'glyphicon-log-in'" + class="glyphicon" + containerClass="popoverContainer"></i> + +<ng-template #loginPopover> + <a + (click)="loginBtnOnclick()" + class="btn btn-sm btn-default" + [href]="logoutHref" + *ngIf="user"> + <i class="glyphicon glyphicon-log-out"></i> + Logout + </a> + <div + *ngIf="!user" + class="btn-group-vertical"> + <a + (click)="loginBtnOnclick()" + class="btn btn-sm btn-default" + *ngFor="let m of loginMethods" + [href]="m.href"> + {{ m.name }} + </a> + </div> +</ng-template> \ No newline at end of file diff --git a/src/ui/pluginBanner/pluginBanner.component.ts b/src/ui/pluginBanner/pluginBanner.component.ts index cef22dab9..11c044191 100644 --- a/src/ui/pluginBanner/pluginBanner.component.ts +++ b/src/ui/pluginBanner/pluginBanner.component.ts @@ -1,12 +1,13 @@ import { Component } from "@angular/core"; -import { PluginServices, PluginManifest } from "../../atlasViewer/atlasViewer.pluginService.service"; +import { PluginServices, PluginManifest } from "src/atlasViewer/atlasViewer.pluginService.service"; @Component({ selector : 'plugin-banner', templateUrl : './pluginBanner.template.html', styleUrls : [ - `./pluginBanner.style.css` + `./pluginBanner.style.css`, + '../../css/darkBtns.css' ] }) diff --git a/src/ui/pluginBanner/pluginBanner.style.css b/src/ui/pluginBanner/pluginBanner.style.css index 208e7f758..2bb0a1504 100644 --- a/src/ui/pluginBanner/pluginBanner.style.css +++ b/src/ui/pluginBanner/pluginBanner.style.css @@ -27,12 +27,6 @@ cursor: default; } -:host-context([darktheme="true"]) .btn-default -{ - background-color:rgba(80,80,80,0.8); - color:white; -} - .btn.btn-disabled.btn.btn-disabled.btn.btn-disabled.btn.btn-disabled { opacity:0.5; diff --git a/src/ui/ui.module.ts b/src/ui/ui.module.ts index aa4d5a440..b8b80de74 100644 --- a/src/ui/ui.module.ts +++ b/src/ui/ui.module.ts @@ -45,6 +45,7 @@ import { HelpComponent } from "./help/help.component"; import { ConfigComponent } from './config/config.component' import { FlatmapArrayPipe } from "src/util/pipes/flatMapArray.pipe"; import { FilterDataEntriesByRegion } from "src/util/pipes/filterDataEntriesByRegion.pipe"; +import { PopoverModule } from 'ngx-bootstrap/popover' @NgModule({ @@ -55,6 +56,7 @@ import { FilterDataEntriesByRegion } from "src/util/pipes/filterDataEntriesByReg LayoutModule, ComponentsModule, + PopoverModule.forRoot(), TooltipModule.forRoot() ], declarations : [ -- GitLab