Merge branch 'master' of https://github.com/plankanban/planka
commit
ea73135ab4
@ -0,0 +1,39 @@
|
|||||||
|
name: Build and push Docker DEV image
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
paths-ignore:
|
||||||
|
- '.github/**'
|
||||||
|
- 'charts/**'
|
||||||
|
- 'docker-*.sh'
|
||||||
|
- '*.md'
|
||||||
|
branches: [master]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push-docker-image-dev:
|
||||||
|
runs-on: self-hosted
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v2
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v2
|
||||||
|
|
||||||
|
- name: Login to GitHub Container Registry
|
||||||
|
uses: docker/login-action@v2
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.repository_owner }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v4
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
ghcr.io/plankanban/planka:dev
|
||||||
@ -1,27 +1,22 @@
|
|||||||
FROM node:lts-alpine
|
FROM node:18-alpine
|
||||||
|
|
||||||
ARG ALPINE_VERSION=3.16
|
ARG VIPS_VERSION=8.14.5
|
||||||
ARG VIPS_VERSION=8.13.3
|
|
||||||
|
|
||||||
RUN apk -U upgrade \
|
RUN apk -U upgrade \
|
||||||
&& apk add \
|
&& apk add \
|
||||||
bash giflib glib lcms2 libexif \
|
bash pkgconf \
|
||||||
libgsf libjpeg-turbo libpng librsvg libwebp \
|
libjpeg-turbo libexif librsvg cgif tiff libspng libimagequant \
|
||||||
orc pango tiff \
|
|
||||||
--repository https://alpine.global.ssl.fastly.net/alpine/v${ALPINE_VERSION}/community/ \
|
|
||||||
--repository https://alpine.global.ssl.fastly.net/alpine/v${ALPINE_VERSION}/main/ \
|
|
||||||
--no-cache \
|
--no-cache \
|
||||||
&& apk add \
|
&& apk add \
|
||||||
build-base giflib-dev glib-dev lcms2-dev libexif-dev \
|
build-base gobject-introspection-dev meson \
|
||||||
libgsf-dev libjpeg-turbo-dev libpng-dev librsvg-dev libwebp-dev \
|
libjpeg-turbo-dev libexif-dev librsvg-dev cgif-dev tiff-dev libspng-dev libimagequant-dev \
|
||||||
orc-dev pango-dev tiff-dev \
|
|
||||||
--virtual vips-dependencies \
|
--virtual vips-dependencies \
|
||||||
--repository https://alpine.global.ssl.fastly.net/alpine/v${ALPINE_VERSION}/community/ \
|
|
||||||
--repository https://alpine.global.ssl.fastly.net/alpine/v${ALPINE_VERSION}/main/ \
|
|
||||||
--no-cache \
|
--no-cache \
|
||||||
&& wget -O- https://github.com/libvips/libvips/releases/download/v${VIPS_VERSION}/vips-${VIPS_VERSION}.tar.gz | tar xzC /tmp \
|
&& wget -O- https://github.com/libvips/libvips/releases/download/v${VIPS_VERSION}/vips-${VIPS_VERSION}.tar.xz | tar xJC /tmp \
|
||||||
&& cd /tmp/vips-${VIPS_VERSION} \
|
&& cd /tmp/vips-${VIPS_VERSION} \
|
||||||
&& ./configure \
|
&& meson setup build-dir \
|
||||||
&& make \
|
&& cd build-dir \
|
||||||
&& make install-strip \
|
&& ninja \
|
||||||
|
&& ninja test \
|
||||||
|
&& ninja install \
|
||||||
&& rm -rf /tmp/vips-${VIPS_VERSION}
|
&& rm -rf /tmp/vips-${VIPS_VERSION}
|
||||||
|
|||||||
@ -0,0 +1,17 @@
|
|||||||
|
{{- if .Values.oidc.enabled }}
|
||||||
|
{{- if eq (and (not (empty .Values.oidc.clientId)) (not (empty .Values.oidc.clientSecret))) (not (empty .Values.oidc.existingSecret)) -}}
|
||||||
|
{{- fail "Either specify inline `clientId` and `clientSecret` or refer to them via `existingSecret`" -}}
|
||||||
|
{{- end }}
|
||||||
|
{{- if (and (and (not (empty .Values.oidc.clientId)) (not (empty .Values.oidc.clientSecret))) (empty .Values.oidc.existingSecret)) -}}
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: {{ include "planka.fullname" . }}-oidc
|
||||||
|
labels:
|
||||||
|
{{- include "planka.labels" . | nindent 4 }}
|
||||||
|
type: Opaque
|
||||||
|
data:
|
||||||
|
clientId: {{ .Values.oidc.clientId | b64enc | quote }}
|
||||||
|
clientSecret: {{ .Values.oidc.clientSecret | b64enc | quote }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
@ -1 +1 @@
|
|||||||
REACT_APP_VERSION=1.12.0
|
REACT_APP_VERSION=1.15.0
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,9 @@
|
|||||||
|
import http from './http';
|
||||||
|
|
||||||
|
/* Actions */
|
||||||
|
|
||||||
|
const getConfig = (headers) => http.get('/config', undefined, headers);
|
||||||
|
|
||||||
|
export default {
|
||||||
|
getConfig,
|
||||||
|
};
|
||||||
@ -0,0 +1,19 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import { Loader } from 'semantic-ui-react';
|
||||||
|
|
||||||
|
import LoginContainer from '../containers/LoginContainer';
|
||||||
|
|
||||||
|
const LoginWrapper = React.memo(({ isInitializing }) => {
|
||||||
|
if (isInitializing) {
|
||||||
|
return <Loader active size="massive" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <LoginContainer />;
|
||||||
|
});
|
||||||
|
|
||||||
|
LoginWrapper.propTypes = {
|
||||||
|
isInitializing: PropTypes.bool.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LoginWrapper;
|
||||||
@ -1,19 +0,0 @@
|
|||||||
import { useAuth } from 'react-oidc-context';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
let isLoggingIn = true;
|
|
||||||
const OidcLogin = React.memo(({ onAuthenticate }) => {
|
|
||||||
const auth = useAuth();
|
|
||||||
if (isLoggingIn && auth.user) {
|
|
||||||
isLoggingIn = false;
|
|
||||||
const { user } = auth;
|
|
||||||
onAuthenticate(user);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
OidcLogin.propTypes = {
|
|
||||||
onAuthenticate: PropTypes.func.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default OidcLogin;
|
|
||||||
@ -1,3 +0,0 @@
|
|||||||
import OidcLogin from './OidcLogin';
|
|
||||||
|
|
||||||
export default OidcLogin;
|
|
||||||
@ -0,0 +1,14 @@
|
|||||||
|
import { connect } from 'react-redux';
|
||||||
|
|
||||||
|
import selectors from '../selectors';
|
||||||
|
import LoginWrapper from '../components/LoginWrapper';
|
||||||
|
|
||||||
|
const mapStateToProps = (state) => {
|
||||||
|
const isInitializing = selectors.selectIsInitializing(state);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isInitializing,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default connect(mapStateToProps)(LoginWrapper);
|
||||||
@ -1,26 +0,0 @@
|
|||||||
import { bindActionCreators } from 'redux';
|
|
||||||
import { connect } from 'react-redux';
|
|
||||||
|
|
||||||
import entryActions from '../entry-actions';
|
|
||||||
import OidcLogin from '../components/OIDC';
|
|
||||||
|
|
||||||
const mapStateToProps = ({
|
|
||||||
ui: {
|
|
||||||
authenticateForm: { data: defaultData, isSubmitting, error },
|
|
||||||
},
|
|
||||||
}) => ({
|
|
||||||
defaultData,
|
|
||||||
isSubmitting,
|
|
||||||
error,
|
|
||||||
});
|
|
||||||
|
|
||||||
const mapDispatchToProps = (dispatch) =>
|
|
||||||
bindActionCreators(
|
|
||||||
{
|
|
||||||
onAuthenticate: entryActions.authenticate,
|
|
||||||
onMessageDismiss: entryActions.clearAuthenticateError,
|
|
||||||
},
|
|
||||||
dispatch,
|
|
||||||
);
|
|
||||||
|
|
||||||
export default connect(mapStateToProps, mapDispatchToProps)(OidcLogin);
|
|
||||||
@ -1,16 +1,10 @@
|
|||||||
import EntryActionTypes from '../constants/EntryActionTypes';
|
import EntryActionTypes from '../constants/EntryActionTypes';
|
||||||
|
|
||||||
const initializeCore = () => ({
|
|
||||||
type: EntryActionTypes.CORE_INITIALIZE,
|
|
||||||
payload: {},
|
|
||||||
});
|
|
||||||
|
|
||||||
const logout = () => ({
|
const logout = () => ({
|
||||||
type: EntryActionTypes.LOGOUT,
|
type: EntryActionTypes.LOGOUT,
|
||||||
payload: {},
|
payload: {},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
initializeCore,
|
|
||||||
logout,
|
logout,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,15 +1,11 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ReactDOM from 'react-dom/client';
|
import ReactDOM from 'react-dom/client';
|
||||||
|
|
||||||
import Config from './constants/Config';
|
|
||||||
import store from './store';
|
import store from './store';
|
||||||
import history from './history';
|
import history from './history';
|
||||||
import Root from './components/Root';
|
import Root from './components/Root';
|
||||||
|
|
||||||
import './i18n';
|
import './i18n';
|
||||||
|
|
||||||
fetch(`${Config.SERVER_BASE_URL}/api/appconfig`).then((response) => {
|
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||||
response.json().then((config) => {
|
root.render(React.createElement(Root, { store, history }));
|
||||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
|
||||||
root.render(React.createElement(Root, { store, history, config }));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@ -0,0 +1,36 @@
|
|||||||
|
import ActionTypes from '../constants/ActionTypes';
|
||||||
|
|
||||||
|
const initialState = {
|
||||||
|
isInitializing: true,
|
||||||
|
config: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// eslint-disable-next-line default-param-last
|
||||||
|
export default (state = initialState, { type, payload }) => {
|
||||||
|
switch (type) {
|
||||||
|
case ActionTypes.LOGIN_INITIALIZE:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
isInitializing: false,
|
||||||
|
config: payload.config,
|
||||||
|
};
|
||||||
|
case ActionTypes.AUTHENTICATE__SUCCESS:
|
||||||
|
case ActionTypes.USING_OIDC_AUTHENTICATE__SUCCESS:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
isInitializing: true,
|
||||||
|
};
|
||||||
|
case ActionTypes.CORE_INITIALIZE:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
isInitializing: false,
|
||||||
|
};
|
||||||
|
case ActionTypes.CORE_INITIALIZE__CONFIG_FETCH:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
config: payload.config,
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -0,0 +1,11 @@
|
|||||||
|
export const selectIsInitializing = ({ root: { isInitializing } }) => isInitializing;
|
||||||
|
|
||||||
|
export const selectConfig = ({ root: { config } }) => config;
|
||||||
|
|
||||||
|
export const selectOidcConfig = (state) => selectConfig(state).oidc;
|
||||||
|
|
||||||
|
export default {
|
||||||
|
selectIsInitializing,
|
||||||
|
selectConfig,
|
||||||
|
selectOidcConfig,
|
||||||
|
};
|
||||||
@ -0,0 +1,16 @@
|
|||||||
|
version: '3'
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- db-data:/var/lib/postgresql/data
|
||||||
|
ports:
|
||||||
|
- 5432:5432
|
||||||
|
environment:
|
||||||
|
- POSTGRES_DB=planka
|
||||||
|
- POSTGRES_HOST_AUTH_METHOD=trust
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
db-data:
|
||||||
@ -1,16 +1,65 @@
|
|||||||
version: '3'
|
version: '3'
|
||||||
|
|
||||||
services:
|
services:
|
||||||
|
planka:
|
||||||
|
image: ghcr.io/plankanban/planka:dev
|
||||||
|
command: >
|
||||||
|
bash -c
|
||||||
|
"for i in `seq 1 30`; do
|
||||||
|
./start.sh &&
|
||||||
|
s=$$? && break || s=$$?;
|
||||||
|
echo \"Tried $$i times. Waiting 5 seconds...\";
|
||||||
|
sleep 5;
|
||||||
|
done; (exit $$s)"
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- user-avatars:/app/public/user-avatars
|
||||||
|
- project-background-images:/app/public/project-background-images
|
||||||
|
- attachments:/app/private/attachments
|
||||||
|
ports:
|
||||||
|
- 3000:1337
|
||||||
|
environment:
|
||||||
|
- BASE_URL=http://localhost:3000
|
||||||
|
- DATABASE_URL=postgresql://postgres@postgres/planka
|
||||||
|
- SECRET_KEY=notsecretkey
|
||||||
|
|
||||||
|
# - TRUST_PROXY=0
|
||||||
|
# - TOKEN_EXPIRES_IN=365 # In days
|
||||||
|
|
||||||
|
# related: https://github.com/knex/knex/issues/2354
|
||||||
|
# As knex does not pass query parameters from the connection string we
|
||||||
|
# have to use environment variables in order to pass the desired values, e.g.
|
||||||
|
# - PGSSLMODE=<value>
|
||||||
|
|
||||||
|
# Configure knex to accept SSL certificates
|
||||||
|
# - KNEX_REJECT_UNAUTHORIZED_SSL_CERTIFICATE=false
|
||||||
|
|
||||||
|
# - DEFAULT_ADMIN_EMAIL=demo@demo.demo # Do not remove if you want to prevent this user from being edited/deleted
|
||||||
|
# - DEFAULT_ADMIN_PASSWORD=demo
|
||||||
|
# - DEFAULT_ADMIN_NAME=Demo Demo
|
||||||
|
# - DEFAULT_ADMIN_USERNAME=demo
|
||||||
|
|
||||||
|
# - OIDC_ISSUER=
|
||||||
|
# - OIDC_CLIENT_ID=
|
||||||
|
# - OIDC_CLIENT_SECRET=
|
||||||
|
# - OIDC_SCOPES=openid email profile
|
||||||
|
# - OIDC_ADMIN_ROLES=admin
|
||||||
|
# - OIDC_ROLES_ATTRIBUTE=groups
|
||||||
|
# - OIDC_IGNORE_ROLES=true
|
||||||
|
depends_on:
|
||||||
|
- postgres
|
||||||
|
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:alpine
|
image: postgres:14-alpine
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
- db-data:/var/lib/postgresql/data
|
- db-data:/var/lib/postgresql/data
|
||||||
ports:
|
|
||||||
- 5432:5432
|
|
||||||
environment:
|
environment:
|
||||||
- POSTGRES_DB=planka
|
- POSTGRES_DB=planka
|
||||||
- POSTGRES_HOST_AUTH_METHOD=trust
|
- POSTGRES_HOST_AUTH_METHOD=trust
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
|
user-avatars:
|
||||||
|
project-background-images:
|
||||||
|
attachments:
|
||||||
db-data:
|
db-data:
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,71 @@
|
|||||||
|
const { getRemoteAddress } = require('../../../utils/remoteAddress');
|
||||||
|
|
||||||
|
const Errors = {
|
||||||
|
INVALID_CODE_OR_NONCE: {
|
||||||
|
invalidCodeOrNonce: 'Invalid code or nonce',
|
||||||
|
},
|
||||||
|
EMAIL_ALREADY_IN_USE: {
|
||||||
|
emailAlreadyInUse: 'Email already in use',
|
||||||
|
},
|
||||||
|
USERNAME_ALREADY_IN_USE: {
|
||||||
|
usernameAlreadyInUse: 'Username already in use',
|
||||||
|
},
|
||||||
|
MISSING_VALUES: {
|
||||||
|
missingValues: 'Unable to retrieve required values (email, name)',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
inputs: {
|
||||||
|
code: {
|
||||||
|
type: 'string',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
nonce: {
|
||||||
|
type: 'string',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
exits: {
|
||||||
|
invalidCodeOrNonce: {
|
||||||
|
responseType: 'unauthorized',
|
||||||
|
},
|
||||||
|
emailAlreadyInUse: {
|
||||||
|
responseType: 'conflict',
|
||||||
|
},
|
||||||
|
usernameAlreadyInUse: {
|
||||||
|
responseType: 'conflict',
|
||||||
|
},
|
||||||
|
missingValues: {
|
||||||
|
responseType: 'unprocessableEntity',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
async fn(inputs) {
|
||||||
|
const remoteAddress = getRemoteAddress(this.req);
|
||||||
|
|
||||||
|
const user = await sails.helpers.users
|
||||||
|
.getOrCreateOneUsingOidc(inputs.code, inputs.nonce)
|
||||||
|
.intercept('invalidCodeOrNonce', () => {
|
||||||
|
sails.log.warn(`Invalid code or nonce! (IP: ${remoteAddress})`);
|
||||||
|
return Errors.INVALID_CODE_OR_NONCE;
|
||||||
|
})
|
||||||
|
.intercept('emailAlreadyInUse', () => Errors.EMAIL_ALREADY_IN_USE)
|
||||||
|
.intercept('usernameAlreadyInUse', () => Errors.USERNAME_ALREADY_IN_USE)
|
||||||
|
.intercept('missingValues', () => Errors.MISSING_VALUES);
|
||||||
|
|
||||||
|
const accessToken = sails.helpers.utils.createToken(user.id);
|
||||||
|
|
||||||
|
await Session.create({
|
||||||
|
accessToken,
|
||||||
|
remoteAddress,
|
||||||
|
userId: user.id,
|
||||||
|
userAgent: this.req.headers['user-agent'],
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
item: accessToken,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -1,177 +0,0 @@
|
|||||||
const jwt = require('jsonwebtoken');
|
|
||||||
const jwksClient = require('jwks-rsa');
|
|
||||||
const openidClient = require('openid-client');
|
|
||||||
const { getRemoteAddress } = require('../../../utils/remoteAddress');
|
|
||||||
|
|
||||||
const Errors = {
|
|
||||||
INVALID_TOKEN: {
|
|
||||||
invalidToken: 'Access Token is invalid',
|
|
||||||
},
|
|
||||||
MISSING_VALUES: {
|
|
||||||
missingValues:
|
|
||||||
'Unable to retrieve required values. Verify the access token or UserInfo endpoint has email, username and name claims',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const jwks = jwksClient({
|
|
||||||
jwksUri: sails.config.custom.oidcJwksUri,
|
|
||||||
requestHeaders: {}, // Optional
|
|
||||||
timeout: 30000, // Defaults to 30s
|
|
||||||
});
|
|
||||||
|
|
||||||
const getJwtVerificationOptions = () => {
|
|
||||||
const options = {};
|
|
||||||
if (sails.config.custom.oidcIssuer) {
|
|
||||||
options.issuer = sails.config.custom.oidcIssuer;
|
|
||||||
}
|
|
||||||
if (sails.config.custom.oidcAudience) {
|
|
||||||
options.audience = sails.config.custom.oidcAudience;
|
|
||||||
}
|
|
||||||
return options;
|
|
||||||
};
|
|
||||||
|
|
||||||
const validateAndDecodeToken = async (accessToken, options) => {
|
|
||||||
const keys = await jwks.getSigningKeys();
|
|
||||||
let validToken = {};
|
|
||||||
|
|
||||||
const isTokenValid = keys.some((signingKey) => {
|
|
||||||
try {
|
|
||||||
const key = signingKey.getPublicKey();
|
|
||||||
validToken = jwt.verify(accessToken, key, options);
|
|
||||||
return 'true';
|
|
||||||
} catch (error) {
|
|
||||||
sails.log.error(error);
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!isTokenValid) {
|
|
||||||
const tokenForLogging = jwt.decode(accessToken);
|
|
||||||
const remoteAddress = getRemoteAddress(this.req);
|
|
||||||
|
|
||||||
sails.log.warn(
|
|
||||||
`invalid token: sub: "${tokenForLogging.sub}" issuer: "${tokenForLogging.iss}" audience: "${tokenForLogging.aud}" exp: ${tokenForLogging.exp} (IP: ${remoteAddress})`,
|
|
||||||
);
|
|
||||||
throw Errors.INVALID_TOKEN;
|
|
||||||
}
|
|
||||||
return validToken;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getUserInfo = async (accessToken, options) => {
|
|
||||||
if (sails.config.custom.oidcSkipUserInfo) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
const issuer = await openidClient.Issuer.discover(options.issuer);
|
|
||||||
const oidcClient = new issuer.Client({
|
|
||||||
client_id: 'irrelevant',
|
|
||||||
});
|
|
||||||
const userInfo = await oidcClient.userinfo(accessToken);
|
|
||||||
return userInfo;
|
|
||||||
};
|
|
||||||
const mergeUserData = (validToken, userInfo) => {
|
|
||||||
const oidcUser = { ...validToken, ...userInfo };
|
|
||||||
return oidcUser;
|
|
||||||
};
|
|
||||||
const getOrCreateUser = async (newUser) => {
|
|
||||||
const user = await User.findOne({
|
|
||||||
where: {
|
|
||||||
username: newUser.username,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (user) {
|
|
||||||
return user;
|
|
||||||
}
|
|
||||||
return User.create(newUser).fetch();
|
|
||||||
};
|
|
||||||
module.exports = {
|
|
||||||
inputs: {
|
|
||||||
token: {
|
|
||||||
type: 'string',
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
exits: {
|
|
||||||
invalidToken: {
|
|
||||||
responseType: 'unauthorized',
|
|
||||||
},
|
|
||||||
missingValues: {
|
|
||||||
responseType: 'unauthorized',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
async fn(inputs) {
|
|
||||||
const options = getJwtVerificationOptions();
|
|
||||||
const validToken = await validateAndDecodeToken(inputs.token, options);
|
|
||||||
const userInfo = await getUserInfo(inputs.token, options);
|
|
||||||
const oidcUser = mergeUserData(validToken, userInfo);
|
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
let isAdmin = false;
|
|
||||||
if (sails.config.custom.oidcAdminRoles.includes('*')) isAdmin = true;
|
|
||||||
else if (Array.isArray(oidcUser[sails.config.custom.oidcRolesAttribute])) {
|
|
||||||
const userRoles = new Set(oidcUser[sails.config.custom.oidcRolesAttribute]);
|
|
||||||
isAdmin = sails.config.custom.oidcAdminRoles.findIndex((role) => userRoles.has(role)) > -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
const newUser = {
|
|
||||||
email: oidcUser.email,
|
|
||||||
isAdmin,
|
|
||||||
name: oidcUser.name,
|
|
||||||
username: oidcUser.preferred_username,
|
|
||||||
subscribeToOwnCards: false,
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
locked: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!newUser.email || !newUser.username || !newUser.name) {
|
|
||||||
sails.log.error(Errors.MISSING_VALUES.missingValues);
|
|
||||||
throw Errors.MISSING_VALUES;
|
|
||||||
}
|
|
||||||
|
|
||||||
const identityProviderUser = await IdentityProviderUser.findOne({
|
|
||||||
where: {
|
|
||||||
issuer: oidcUser.iss,
|
|
||||||
sub: oidcUser.sub,
|
|
||||||
},
|
|
||||||
}).populate('userId');
|
|
||||||
|
|
||||||
let user = identityProviderUser ? identityProviderUser.userId : {};
|
|
||||||
if (!identityProviderUser) {
|
|
||||||
user = await getOrCreateUser(newUser);
|
|
||||||
await IdentityProviderUser.create({
|
|
||||||
issuer: oidcUser.iss,
|
|
||||||
sub: oidcUser.sub,
|
|
||||||
userId: user.id,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const controlledFields = ['email', 'password', 'isAdmin', 'name', 'username'];
|
|
||||||
const updateFields = {};
|
|
||||||
controlledFields.forEach((field) => {
|
|
||||||
if (user[field] !== newUser[field]) {
|
|
||||||
updateFields[field] = newUser[field];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (Object.keys(updateFields).length > 0) {
|
|
||||||
updateFields.updatedAt = now;
|
|
||||||
await User.updateOne({ id: user.id }).set(updateFields);
|
|
||||||
}
|
|
||||||
|
|
||||||
const plankaToken = sails.helpers.utils.createToken(user.id);
|
|
||||||
|
|
||||||
const remoteAddress = getRemoteAddress(this.req);
|
|
||||||
await Session.create({
|
|
||||||
accessToken: plankaToken,
|
|
||||||
remoteAddress,
|
|
||||||
userId: user.id,
|
|
||||||
userAgent: this.req.headers['user-agent'],
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
item: plankaToken,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@ -1,11 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
async fn() {
|
|
||||||
const config = {
|
|
||||||
authority: sails.config.custom.oidcIssuer,
|
|
||||||
clientId: sails.config.custom.oidcClientId,
|
|
||||||
redirectUri: sails.config.custom.oidcredirectUri,
|
|
||||||
scopes: sails.config.custom.oidcScopes,
|
|
||||||
};
|
|
||||||
return config;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
module.exports = {
|
||||||
|
fn() {
|
||||||
|
let oidc = null;
|
||||||
|
if (sails.hooks.oidc.isActive()) {
|
||||||
|
const oidcClient = sails.hooks.oidc.getClient();
|
||||||
|
|
||||||
|
oidc = {
|
||||||
|
authorizationUrl: oidcClient.authorizationUrl({
|
||||||
|
scope: sails.config.custom.oidcScopes,
|
||||||
|
response_mode: 'fragment',
|
||||||
|
}),
|
||||||
|
endSessionUrl: oidcClient.issuer.end_session_endpoint ? oidcClient.endSessionUrl({}) : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
item: {
|
||||||
|
oidc,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -0,0 +1,113 @@
|
|||||||
|
module.exports = {
|
||||||
|
inputs: {
|
||||||
|
code: {
|
||||||
|
type: 'string',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
nonce: {
|
||||||
|
type: 'string',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
exits: {
|
||||||
|
invalidCodeOrNonce: {},
|
||||||
|
missingValues: {},
|
||||||
|
emailAlreadyInUse: {},
|
||||||
|
usernameAlreadyInUse: {},
|
||||||
|
},
|
||||||
|
|
||||||
|
async fn(inputs) {
|
||||||
|
const client = sails.hooks.oidc.getClient();
|
||||||
|
|
||||||
|
let userInfo;
|
||||||
|
try {
|
||||||
|
const tokenSet = await client.callback(
|
||||||
|
sails.config.custom.oidcRedirectUri,
|
||||||
|
{ code: inputs.code },
|
||||||
|
{ nonce: inputs.nonce },
|
||||||
|
);
|
||||||
|
userInfo = await client.userinfo(tokenSet);
|
||||||
|
} catch (e) {
|
||||||
|
sails.log.warn(`Error while exchanging OIDC code: ${e}`);
|
||||||
|
throw 'invalidCodeOrNonce';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!userInfo.email || !userInfo.name) {
|
||||||
|
throw 'missingValues';
|
||||||
|
}
|
||||||
|
|
||||||
|
let isAdmin = false;
|
||||||
|
if (sails.config.custom.oidcAdminRoles.includes('*')) {
|
||||||
|
isAdmin = true;
|
||||||
|
} else {
|
||||||
|
const roles = userInfo[sails.config.custom.oidcRolesAttribute];
|
||||||
|
if (Array.isArray(roles)) {
|
||||||
|
// Use a Set here to avoid quadratic time complexity
|
||||||
|
const userRoles = new Set(userInfo[sails.config.custom.oidcRolesAttribute]);
|
||||||
|
isAdmin = sails.config.custom.oidcAdminRoles.findIndex((role) => userRoles.has(role)) > -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const values = {
|
||||||
|
isAdmin,
|
||||||
|
email: userInfo.email,
|
||||||
|
isSso: true,
|
||||||
|
name: userInfo.name,
|
||||||
|
username: userInfo.preferred_username,
|
||||||
|
subscribeToOwnCards: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
let user;
|
||||||
|
// This whole block technically needs to be executed in a transaction
|
||||||
|
// with SERIALIZABLE isolation level (but Waterline does not support
|
||||||
|
// that), so this will result in errors if for example users are deleted
|
||||||
|
// concurrently with logging in via OIDC.
|
||||||
|
let identityProviderUser = await IdentityProviderUser.findOne({
|
||||||
|
issuer: sails.config.custom.oidcIssuer,
|
||||||
|
sub: userInfo.sub,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (identityProviderUser) {
|
||||||
|
user = await sails.helpers.users.getOne(identityProviderUser.userId);
|
||||||
|
} else {
|
||||||
|
// If no IDP/User mapping exists, search for the user by email.
|
||||||
|
user = await sails.helpers.users.getOne({
|
||||||
|
email: values.email.toLowerCase(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Otherwise, create a new user.
|
||||||
|
if (!user) {
|
||||||
|
user = await sails.helpers.users
|
||||||
|
.createOne(values)
|
||||||
|
.intercept('usernameAlreadyInUse', 'usernameAlreadyInUse');
|
||||||
|
}
|
||||||
|
|
||||||
|
identityProviderUser = await IdentityProviderUser.create({
|
||||||
|
userId: user.id,
|
||||||
|
issuer: sails.config.custom.oidcIssuer,
|
||||||
|
sub: userInfo.sub,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateFieldKeys = ['email', 'isSso', 'name', 'username'];
|
||||||
|
if (!sails.config.custom.oidcIgnoreRoles) {
|
||||||
|
updateFieldKeys.push('isAdmin');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateValues = {};
|
||||||
|
// eslint-disable-next-line no-restricted-syntax
|
||||||
|
for (const k of updateFieldKeys) {
|
||||||
|
if (values[k] !== user[k]) updateValues[k] = values[k];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(updateValues).length > 0) {
|
||||||
|
user = await sails.helpers.users
|
||||||
|
.updateOne(user, updateValues, {}) // FIXME: hack for last parameter
|
||||||
|
.intercept('emailAlreadyInUse', 'emailAlreadyInUse')
|
||||||
|
.intercept('usernameAlreadyInUse', 'usernameAlreadyInUse');
|
||||||
|
}
|
||||||
|
|
||||||
|
return user;
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -0,0 +1,33 @@
|
|||||||
|
const openidClient = require('openid-client');
|
||||||
|
|
||||||
|
module.exports = function oidcServiceHook(sails) {
|
||||||
|
let client = null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
/**
|
||||||
|
* Runs when this Sails app loads/lifts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
async initialize() {
|
||||||
|
if (sails.config.custom.oidcIssuer) {
|
||||||
|
const issuer = await openidClient.Issuer.discover(sails.config.custom.oidcIssuer);
|
||||||
|
|
||||||
|
client = new issuer.Client({
|
||||||
|
client_id: sails.config.custom.oidcClientId,
|
||||||
|
client_secret: sails.config.custom.oidcClientSecret,
|
||||||
|
redirect_uris: [sails.config.custom.oidcRedirectUri],
|
||||||
|
response_types: ['code'],
|
||||||
|
});
|
||||||
|
sails.log.info('OIDC hook has been loaded successfully');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
getClient() {
|
||||||
|
return client;
|
||||||
|
},
|
||||||
|
|
||||||
|
isActive() {
|
||||||
|
return client !== null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -1,24 +0,0 @@
|
|||||||
module.exports.up = (knex) =>
|
|
||||||
knex.schema.createTable('identity_provider_user', (table) => {
|
|
||||||
/* Columns */
|
|
||||||
|
|
||||||
table.bigInteger('id').primary().defaultTo(knex.raw('next_id()'));
|
|
||||||
table.timestamp('created_at', true);
|
|
||||||
table.timestamp('updated_at', true);
|
|
||||||
|
|
||||||
table
|
|
||||||
.bigInteger('user_id')
|
|
||||||
.notNullable()
|
|
||||||
.references('id')
|
|
||||||
.inTable('user_account')
|
|
||||||
.onDelete('CASCADE');
|
|
||||||
|
|
||||||
table.text('issuer').notNullable();
|
|
||||||
table.text('sub').notNullable();
|
|
||||||
|
|
||||||
/* Indexes */
|
|
||||||
|
|
||||||
table.index('user_id');
|
|
||||||
});
|
|
||||||
|
|
||||||
module.exports.down = (knex) => knex.schema.dropTable('identity_provider_user');
|
|
||||||
@ -0,0 +1,44 @@
|
|||||||
|
module.exports.up = async (knex) => {
|
||||||
|
await knex.schema.createTable('identity_provider_user', (table) => {
|
||||||
|
/* Columns */
|
||||||
|
|
||||||
|
table.bigInteger('id').primary().defaultTo(knex.raw('next_id()'));
|
||||||
|
|
||||||
|
table.bigInteger('user_id').notNullable();
|
||||||
|
|
||||||
|
table.text('issuer').notNullable();
|
||||||
|
table.text('sub').notNullable();
|
||||||
|
|
||||||
|
table.timestamp('created_at', true);
|
||||||
|
table.timestamp('updated_at', true);
|
||||||
|
|
||||||
|
/* Indexes */
|
||||||
|
|
||||||
|
table.unique(['issuer', 'sub']);
|
||||||
|
table.index('user_id');
|
||||||
|
});
|
||||||
|
|
||||||
|
await knex.schema.table('user_account', (table) => {
|
||||||
|
/* Columns */
|
||||||
|
|
||||||
|
table.boolean('is_sso').notNullable().default(false);
|
||||||
|
|
||||||
|
/* Modifications */
|
||||||
|
|
||||||
|
table.setNullable('password');
|
||||||
|
});
|
||||||
|
|
||||||
|
return knex.schema.alterTable('user_account', (table) => {
|
||||||
|
table.boolean('is_sso').notNullable().alter();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports.down = async (knex) => {
|
||||||
|
await knex.schema.dropTable('identity_provider_user');
|
||||||
|
|
||||||
|
return knex.schema.table('user_account', (table) => {
|
||||||
|
table.dropColumn('is_sso');
|
||||||
|
|
||||||
|
table.dropNullable('password');
|
||||||
|
});
|
||||||
|
};
|
||||||
@ -1,11 +0,0 @@
|
|||||||
module.exports.up = async (knex) => {
|
|
||||||
return knex.schema.table('user_account', (table) => {
|
|
||||||
table.setNullable('password');
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
module.exports.down = async (knex) => {
|
|
||||||
return knex.schema.table('user_account', (table) => {
|
|
||||||
table.dropNullable('password');
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@ -1,11 +0,0 @@
|
|||||||
module.exports.up = async (knex) => {
|
|
||||||
return knex.schema.table('user_account', (table) => {
|
|
||||||
table.boolean('locked').default(false);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
module.exports.down = async (knex) => {
|
|
||||||
return knex.schema.table('user_account', (table) => {
|
|
||||||
table.dropColumn('locked');
|
|
||||||
});
|
|
||||||
};
|
|
||||||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue