1
0
Fork 0
mirror of https://github.com/plankanban/planka.git synced 2025-07-19 21:29:43 +02:00

feat: Add ability to enforce SSO

Closes #543, closes #545
This commit is contained in:
Maksim Eltyshev 2024-02-01 00:31:15 +01:00
parent 0dc24ffafd
commit 5e8027f5d6
10 changed files with 80 additions and 46 deletions

View file

@ -68,6 +68,7 @@ const Login = React.memo(
isSubmittingUsingOidc, isSubmittingUsingOidc,
error, error,
withOidc, withOidc,
isOidcEnforced,
onAuthenticate, onAuthenticate,
onAuthenticateUsingOidc, onAuthenticateUsingOidc,
onMessageDismiss, onMessageDismiss,
@ -107,8 +108,10 @@ const Login = React.memo(
}, [onAuthenticate, data]); }, [onAuthenticate, data]);
useEffect(() => { useEffect(() => {
emailOrUsernameField.current.focus(); if (!isOidcEnforced) {
}, []); emailOrUsernameField.current.focus();
}
}, [isOidcEnforced]);
useEffect(() => { useEffect(() => {
if (wasSubmitting && !isSubmitting && error) { if (wasSubmitting && !isSubmitting && error) {
@ -159,51 +162,57 @@ const Login = React.memo(
onDismiss={onMessageDismiss} onDismiss={onMessageDismiss}
/> />
)} )}
<Form size="large" onSubmit={handleSubmit}> {!isOidcEnforced && (
<div className={styles.inputWrapper}> <Form size="large" onSubmit={handleSubmit}>
<div className={styles.inputLabel}>{t('common.emailOrUsername')}</div> <div className={styles.inputWrapper}>
<Input <div className={styles.inputLabel}>{t('common.emailOrUsername')}</div>
fluid <Input
ref={emailOrUsernameField} fluid
name="emailOrUsername" ref={emailOrUsernameField}
value={data.emailOrUsername} name="emailOrUsername"
readOnly={isSubmitting} value={data.emailOrUsername}
className={styles.input} readOnly={isSubmitting}
onChange={handleFieldChange} className={styles.input}
onChange={handleFieldChange}
/>
</div>
<div className={styles.inputWrapper}>
<div className={styles.inputLabel}>{t('common.password')}</div>
<Input.Password
fluid
ref={passwordField}
name="password"
value={data.password}
readOnly={isSubmitting}
className={styles.input}
onChange={handleFieldChange}
/>
</div>
<Form.Button
primary
size="large"
icon="right arrow"
labelPosition="right"
content={t('action.logIn')}
floated="right"
loading={isSubmitting}
disabled={isSubmitting || isSubmittingUsingOidc}
/> />
</div> </Form>
<div className={styles.inputWrapper}> )}
<div className={styles.inputLabel}>{t('common.password')}</div>
<Input.Password
fluid
ref={passwordField}
name="password"
value={data.password}
readOnly={isSubmitting}
className={styles.input}
onChange={handleFieldChange}
/>
</div>
<Form.Button
primary
size="large"
icon="right arrow"
labelPosition="right"
content={t('action.logIn')}
floated="right"
loading={isSubmitting}
disabled={isSubmitting || isSubmittingUsingOidc}
/>
</Form>
{withOidc && ( {withOidc && (
<Button <Button
type="button" type="button"
fluid={isOidcEnforced}
primary={isOidcEnforced}
size={isOidcEnforced ? 'large' : undefined}
icon={isOidcEnforced ? 'right arrow' : undefined}
labelPosition={isOidcEnforced ? 'right' : undefined}
content={t('action.logInWithSSO')}
loading={isSubmittingUsingOidc} loading={isSubmittingUsingOidc}
disabled={isSubmitting || isSubmittingUsingOidc} disabled={isSubmitting || isSubmittingUsingOidc}
onClick={onAuthenticateUsingOidc} onClick={onAuthenticateUsingOidc}
> />
{t('action.logInWithSSO')}
</Button>
)} )}
</div> </div>
</div> </div>
@ -242,6 +251,7 @@ Login.propTypes = {
isSubmittingUsingOidc: PropTypes.bool.isRequired, isSubmittingUsingOidc: PropTypes.bool.isRequired,
error: PropTypes.object, // eslint-disable-line react/forbid-prop-types error: PropTypes.object, // eslint-disable-line react/forbid-prop-types
withOidc: PropTypes.bool.isRequired, withOidc: PropTypes.bool.isRequired,
isOidcEnforced: PropTypes.bool.isRequired,
onAuthenticate: PropTypes.func.isRequired, onAuthenticate: PropTypes.func.isRequired,
onAuthenticateUsingOidc: PropTypes.func.isRequired, onAuthenticateUsingOidc: PropTypes.func.isRequired,
onMessageDismiss: PropTypes.func.isRequired, onMessageDismiss: PropTypes.func.isRequired,

View file

@ -10,6 +10,7 @@ import Item from './Item';
const UsersModal = React.memo( const UsersModal = React.memo(
({ ({
items, items,
canAdd,
onUpdate, onUpdate,
onUsernameUpdate, onUsernameUpdate,
onUsernameUpdateMessageDismiss, onUsernameUpdateMessageDismiss,
@ -130,11 +131,13 @@ const UsersModal = React.memo(
</Table.Body> </Table.Body>
</Table> </Table>
</Modal.Content> </Modal.Content>
<Modal.Actions> {canAdd && (
<UserAddPopupContainer> <Modal.Actions>
<Button positive content={t('action.addUser')} /> <UserAddPopupContainer>
</UserAddPopupContainer> <Button positive content={t('action.addUser')} />
</Modal.Actions> </UserAddPopupContainer>
</Modal.Actions>
)}
</Modal> </Modal>
); );
}, },
@ -142,6 +145,7 @@ const UsersModal = React.memo(
UsersModal.propTypes = { UsersModal.propTypes = {
items: PropTypes.array.isRequired, // eslint-disable-line react/forbid-prop-types items: PropTypes.array.isRequired, // eslint-disable-line react/forbid-prop-types
canAdd: PropTypes.bool.isRequired,
onUpdate: PropTypes.func.isRequired, onUpdate: PropTypes.func.isRequired,
onUsernameUpdate: PropTypes.func.isRequired, onUsernameUpdate: PropTypes.func.isRequired,
onUsernameUpdateMessageDismiss: PropTypes.func.isRequired, onUsernameUpdateMessageDismiss: PropTypes.func.isRequired,

View file

@ -20,6 +20,7 @@ const mapStateToProps = (state) => {
isSubmittingUsingOidc, isSubmittingUsingOidc,
error, error,
withOidc: !!oidcConfig, withOidc: !!oidcConfig,
isOidcEnforced: oidcConfig && oidcConfig.isEnforced,
}; };
}; };

View file

@ -6,10 +6,12 @@ import entryActions from '../entry-actions';
import UsersModal from '../components/UsersModal'; import UsersModal from '../components/UsersModal';
const mapStateToProps = (state) => { const mapStateToProps = (state) => {
const oidcConfig = selectors.selectOidcConfig(state);
const users = selectors.selectUsersExceptCurrent(state); const users = selectors.selectUsersExceptCurrent(state);
return { return {
items: users, items: users,
canAdd: !oidcConfig || !oidcConfig.isEnforced,
}; };
}; };

View file

@ -50,6 +50,7 @@ services:
# - OIDC_ROLES_ATTRIBUTE=groups # - OIDC_ROLES_ATTRIBUTE=groups
# - OIDC_IGNORE_USERNAME=true # - OIDC_IGNORE_USERNAME=true
# - OIDC_IGNORE_ROLES=true # - OIDC_IGNORE_ROLES=true
# - OIDC_ENFORCED=true
depends_on: depends_on:
- postgres - postgres

View file

@ -33,6 +33,7 @@ SECRET_KEY=notsecretkey
# OIDC_ROLES_ATTRIBUTE=groups # OIDC_ROLES_ATTRIBUTE=groups
# OIDC_IGNORE_USERNAME=true # OIDC_IGNORE_USERNAME=true
# OIDC_IGNORE_ROLES=true # OIDC_IGNORE_ROLES=true
# OIDC_ENFORCED=true
## Do not edit this ## Do not edit this

View file

@ -46,8 +46,11 @@ module.exports = {
}, },
async fn(inputs) { async fn(inputs) {
const remoteAddress = getRemoteAddress(this.req); if (sails.config.custom.oidcEnforced) {
throw Errors.USE_SINGLE_SIGN_ON;
}
const remoteAddress = getRemoteAddress(this.req);
const user = await sails.helpers.users.getOneByEmailOrUsername(inputs.emailOrUsername); const user = await sails.helpers.users.getOneByEmailOrUsername(inputs.emailOrUsername);
if (!user) { if (!user) {

View file

@ -10,6 +10,7 @@ module.exports = {
response_mode: 'fragment', response_mode: 'fragment',
}), }),
endSessionUrl: oidcClient.issuer.end_session_endpoint ? oidcClient.endSessionUrl({}) : null, endSessionUrl: oidcClient.issuer.end_session_endpoint ? oidcClient.endSessionUrl({}) : null,
isEnforced: sails.config.custom.oidcEnforced,
}; };
} }

View file

@ -1,6 +1,9 @@
const zxcvbn = require('zxcvbn'); const zxcvbn = require('zxcvbn');
const Errors = { const Errors = {
NOT_ENOUGH_RIGHTS: {
notEnoughRights: 'Not enough rights',
},
EMAIL_ALREADY_IN_USE: { EMAIL_ALREADY_IN_USE: {
emailAlreadyInUse: 'Email already in use', emailAlreadyInUse: 'Email already in use',
}, },
@ -56,6 +59,9 @@ module.exports = {
}, },
exits: { exits: {
notEnoughRights: {
responseType: 'forbidden',
},
emailAlreadyInUse: { emailAlreadyInUse: {
responseType: 'conflict', responseType: 'conflict',
}, },
@ -65,6 +71,10 @@ module.exports = {
}, },
async fn(inputs) { async fn(inputs) {
if (sails.config.custom.oidcEnforced) {
throw Errors.NOT_ENOUGH_RIGHTS;
}
const values = _.pick(inputs, [ const values = _.pick(inputs, [
'email', 'email',
'password', 'password',

View file

@ -44,6 +44,7 @@ module.exports.custom = {
oidcRolesAttribute: process.env.OIDC_ROLES_ATTRIBUTE || 'groups', oidcRolesAttribute: process.env.OIDC_ROLES_ATTRIBUTE || 'groups',
oidcIgnoreUsername: process.env.OIDC_IGNORE_USERNAME === 'true', oidcIgnoreUsername: process.env.OIDC_IGNORE_USERNAME === 'true',
oidcIgnoreRoles: process.env.OIDC_IGNORE_ROLES === 'true', oidcIgnoreRoles: process.env.OIDC_IGNORE_ROLES === 'true',
oidcEnforced: process.env.OIDC_ENFORCED === 'true',
// TODO: move client base url to environment variable? // TODO: move client base url to environment variable?
oidcRedirectUri: `${ oidcRedirectUri: `${