Merge pull request #3351 from westlywright/bug.user.change.password

Add `userMustChangePassword` Handling
This commit is contained in:
Richard Cox 2021-07-07 16:16:53 +01:00 committed by GitHub
commit 309ef29dcb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 259 additions and 106 deletions

View File

@ -776,7 +776,7 @@ changePassword:
deleteKeys:
label: Delete all existing API keys
changeOnLogin:
label: Ask user to change their password on first login
label: Ask user to change their password on next login
generatePassword:
label: Generate a random password
currentPassword:
@ -2594,7 +2594,7 @@ probe:
placeholder: Select a check type
project:
members:
members:
label: Members
user: User
role: Role
@ -3133,13 +3133,16 @@ servicesPage:
label: Service Type
setup:
welcome: Welcome to {vendor}!
setPassword: The first order of business is to set a strong password for the default <code>admin</code> user. We suggest using this random one generated just for you, but enter your own if you like.
newPassword: New Password
confirmPassword: Confirm New Password
useRandom: Use a randomly generated password
useManual: Set a specific password to use
defaultPasswordError: It looks like this is your first time visting the Rancher UI, but the local admin account password is already set to something unique. Log in with that account below to continue the setup process.
eula: I agree to the <a href="https://rancher.com/eula" target="_blank" rel="noopener noreferrer nofollow">terms and conditions</a> for using Rancher.
newPassword: New Password
newUserSetPassword: The first order of business is to set a strong password. We suggest using this random one generated just for you, but enter your own if you like.
serverUrl:
label: Server URL
skip: Skip
tip: What URL should be used for this Rancher installation? All the nodes in your clusters will need to be able to reach this. You can skip setting this for now, and update it later in General Settings>Advanced Settings.
setPassword: The first order of business is to set a strong password for the default <code>{username}</code> user. We suggest using this random one generated just for you, but enter your own if you like.
telemetry:
label: Allow collection of anonymous statistics to help us improve Rancher
tip: 'Rancher Labs would like to collect a bit of anonymized information
@ -3148,11 +3151,9 @@ setup:
what specific resources or endpoints you are deploying is included.
Once enabled you can view exactly what data will be sent at <code>/v1-telemetry</code>.
<a href="https://rancher.com/docs/rancher/v2.x/en/faq/telemetry/" target="_blank" rel="noopener noreferrer nofollow">More Info</a>'
eula: I agree to the <a href="https://rancher.com/eula" target="_blank" rel="noopener noreferrer nofollow">terms and conditions</a> for using Rancher.
serverUrl:
label: Server URL
tip: What URL should be used for this Rancher installation? All the nodes in your clusters will need to be able to reach this. You can skip setting this for now, and update it later in General Settings>Advanced Settings.
skip: Skip
useManual: Set a specific password to use
useRandom: Use a randomly generated password
welcome: Welcome to {vendor}!
sortableTable:
actionAvailability:

View File

@ -19,6 +19,10 @@ export default {
type: String,
default: null
},
mustChangePassword: {
type: Boolean,
default: false
}
},
async fetch() {
if (this.isChange) {
@ -27,11 +31,11 @@ export default {
type: NORMAN.USER,
opt: { url: '/v3/users', filter: { me: true } }
});
const user = users?.[0];
if (users && users.length === 1) {
this.username = users[0].username;
}
this.username = user?.username;
}
this.userChangeOnLogin = this.mustChangePassword;
},
data(ctx) {
return {

View File

@ -212,6 +212,7 @@ export default {
ref="changePassword"
v-model="form.password"
:mode="mode"
:must-change-password="value.mustChangePassword"
@valid="validation.password = $event"
/>
</div>

View File

@ -93,10 +93,10 @@ export default async function({
if (ok) {
if (initialPass) {
return redirect({ name: 'auth-setup', query: { [SETUP]: initialPass } });
} else {
return redirect({ name: 'auth-setup' });
store.dispatch('auth/setInitialPass', initialPass);
}
return redirect({ name: 'auth-setup' });
} else {
const t = store.getters['i18n/t'];
@ -125,6 +125,13 @@ export default async function({
}
if ( store.getters['auth/enabled'] !== false && !store.getters['auth/loggedIn'] ) {
await store.dispatch('auth/getUser');
const v3User = store.getters['auth/v3User'] || {};
if (v3User?.mustChangePassword) {
return redirect({ name: 'auth-setup' });
}
// In newer versions the API calls return the auth state instead of having to make a new call all the time.
const fromHeader = store.getters['auth/fromHeader'];

View File

@ -11,7 +11,7 @@ import { configType } from '@/models/management.cattle.io.authconfig';
import { mapGetters } from 'vuex';
import { importLogin } from '@/utils/dynamic-importer';
import { _ALL_IF_AUTHED } from '@/plugins/steve/actions';
import { MANAGEMENT } from '@/config/types';
import { MANAGEMENT, NORMAN } from '@/config/types';
import { SETTING } from '@/config/settings';
import { LOGIN_ERRORS } from '@/store/auth';
import { getVendor, getProduct, setVendor } from '../../config/private-label';
@ -190,6 +190,18 @@ export default {
password: this.password
}
});
const user = await this.$store.dispatch('rancher/findAll', {
type: NORMAN.USER,
opt: { url: '/v3/users?me=true' }
});
if (!!user?.[0]) {
this.$store.dispatch('auth/gotUser', user[0]);
this.needsSetup = this.needsSetup || user[0].mustChangePassword;
}
if ( this.remember ) {
this.$cookies.set(USERNAME, this.username, {
encode: x => x,
@ -202,7 +214,8 @@ export default {
}
if (this.needsSetup) {
this.$router.push({ name: 'auth-setup', query: { setup: this.password } });
this.$store.dispatch('auth/setInitialPass', this.password);
this.$router.push({ name: 'auth-setup' });
} else {
this.$router.replace('/');
}
@ -318,7 +331,12 @@ export default {
}
.login-messages {
height: 20px
height: 20px;
display: flex;
justify-content: center;
.text-error {
max-width: 80%;
}
}
}
</style>

View File

@ -3,7 +3,7 @@ import { randomStr } from '@/utils/string';
import LabeledInput from '@/components/form/LabeledInput';
import CopyToClipboard from '@/components/CopyToClipboard';
import AsyncButton from '@/components/AsyncButton';
import { SETUP } from '@/config/query-params';
import { LOGGED_OUT, SETUP } from '@/config/query-params';
import { NORMAN, MANAGEMENT } from '@/config/types';
import { findBy } from '@/utils/array';
import Checkbox from '@/components/form/Checkbox';
@ -12,11 +12,24 @@ import RadioGroup from '@/components/form/RadioGroup';
import { setSetting, SETTING } from '@/config/settings';
import { _ALL_IF_AUTHED } from '@/plugins/steve/actions';
import { isDevBuild } from '@/utils/version';
import { exceptionToErrorsArray } from '@/utils/error';
const calcIsFirstLogin = (store) => {
const firstLoginSetting = store.getters['management/byId'](MANAGEMENT.SETTING, SETTING.FIRST_LOGIN);
return firstLoginSetting?.value === 'true';
};
const calcMustChangePassword = async(store) => {
await store.dispatch('auth/getUser');
return store.getters['auth/v3User']?.mustChangePassword;
};
export default {
layout: 'unauthenticated',
async middleware({ store, redirect } ) {
async middleware({ store, redirect, route } ) {
try {
await store.dispatch('management/findAll', {
type: MANAGEMENT.SETTING,
@ -27,11 +40,26 @@ export default {
} catch (e) {
}
const firstLoginSetting = store.getters['management/byId'](MANAGEMENT.SETTING, SETTING.FIRST_LOGIN);
const isFirstLogin = calcIsFirstLogin(store);
const mustChangePassword = calcMustChangePassword(store);
if (firstLoginSetting?.value !== 'true') {
return redirect('/');
if (isFirstLogin) {
// Always show setup if this is the first log in
return;
} else if (mustChangePassword) {
// If the password needs changing and this isn't the first log in ensure we have the password
if (!!store.getters['auth/initialPass']) {
// Got it... show setup
return;
}
// Haven't got it... redirect to log in so we get it
await store.dispatch('auth/logout', null, { root: true });
return redirect(302, `/auth/login?${ LOGGED_OUT }`);
}
// For all other cases we don't need to show setup
return redirect('/');
},
components: {
@ -51,8 +79,10 @@ export default {
}
const principals = await store.dispatch('rancher/findAll', { type: NORMAN.PRINCIPAL, opt: { url: '/v3/principals' } });
const me = findBy(principals, 'me', true);
const current = route.query[SETUP] || 'admin';
const current = route.query[SETUP] || store.getters['auth/initialPass'] || 'admin';
const v3User = store.getters['auth/v3User'] ?? {};
let serverUrl;
@ -64,17 +94,24 @@ export default {
serverUrl = window.location.origin;
}
const isFirstLogin = calcIsFirstLogin(store);
const mustChangePassword = calcMustChangePassword(store);
return {
vendor: getVendor(),
product: getProduct(),
step: parseInt(route.query.step, 10) || 1,
useRandom: false,
haveCurrent: !!current,
username: 'admin',
useRandom: true,
haveCurrent: !!current,
username: me?.loginName || 'admin',
mustSetup: isFirstLogin,
mustChangePassword: isFirstLogin || mustChangePassword,
current,
password: '',
confirm: '',
password: randomStr(),
confirm: '',
v3User,
serverUrl,
@ -89,7 +126,7 @@ export default {
computed: {
passwordSubmitDisabled() {
if (!this.eula) {
if (!this.eula && this.mustSetup) {
return true;
}
@ -138,13 +175,15 @@ export default {
methods: {
async finishPassword(buttonCb) {
try {
await this.$store.dispatch('loadManagement');
if (this.mustSetup) {
await this.$store.dispatch('loadManagement');
await Promise.all([
setSetting(this.$store, SETTING.EULA_AGREED, (new Date()).toISOString() ),
setSetting(this.$store, SETTING.TELEMETRY, this.telemetry ? 'in' : 'out'),
setSetting(this.$store, SETTING.FIRST_LOGIN, 'false'),
]);
await Promise.all([
setSetting(this.$store, SETTING.EULA_AGREED, (new Date()).toISOString() ),
setSetting(this.$store, SETTING.TELEMETRY, this.telemetry ? 'in' : 'out'),
setSetting(this.$store, SETTING.FIRST_LOGIN, 'false'),
]);
}
await this.$store.dispatch('rancher/request', {
url: '/v3/users?action=changepassword',
@ -154,10 +193,22 @@ export default {
newPassword: this.password
},
});
this.step = 2;
buttonCb(true);
const user = this.v3User;
user.mustChangePassword = false;
this.$store.dispatch('auth/gotUser', user);
if (!this.mustSetup && this.mustChangePassword) {
buttonCb(true);
this.done();
} else {
this.step = 2;
buttonCb(true);
}
} catch (err) {
buttonCb(false);
this.errors = exceptionToErrorsArray(err);
}
},
@ -177,77 +228,90 @@ export default {
},
};
</script>
<template>
<div class="setup">
<div class="row">
<div class="col span-6">
<h1 class="text-center">
{{ t('setup.welcome', {product}) }}
</h1>
<div class="col span-6 form-col">
<div>
&nbsp;
</div>
<div>
<h1 class="text-center">
{{ t('setup.welcome', {product}) }}
</h1>
<template v-if="step===1">
<p class="text-center mb-40 mt-20 setup-title">
<t k="setup.setPassword" :raw="true" />
</p>
<template v-if="step===1">
<p
class="text-center mb-40 mt-20 setup-title"
v-html="t(mustSetup ? 'setup.setPassword' : 'setup.newUserSetPassword', { username }, true)"
></p>
<!-- For password managers... -->
<input type="hidden" name="username" autocomplete="username" :value="username" />
<div class="mb-20">
<RadioGroup v-model="useRandom" name="password-mode" :options="[{label: t('setup.useRandom'), value: true}, {label: t('setup.useManual'), value: false}]" />
</div>
<div class="mb-20">
<!-- For password managers... -->
<input type="hidden" name="username" autocomplete="username" :value="username" />
<div class="mb-20">
<RadioGroup v-model="useRandom" name="password-mode" :options="[{label: t('setup.useRandom'), value: true}, {label: t('setup.useManual'), value: false}]" />
</div>
<div class="mb-20">
<LabeledInput
ref="password"
v-model.trim="password"
:type="useRandom ? 'text' : 'password'"
:disabled="useRandom"
label-key="setup.newPassword"
>
<template v-if="useRandom" #suffix>
<div class="addon" style="padding: 0 0 0 12px;">
<CopyToClipboard :text="password" class="btn-sm" />
</div>
</template>
</LabeledInput>
</div>
<LabeledInput
ref="password"
v-model.trim="password"
:type="useRandom ? 'text' : 'password'"
:disabled="useRandom"
label-key="setup.newPassword"
>
<template v-if="useRandom" #suffix>
<div class="addon" style="padding: 0 0 0 12px;">
<CopyToClipboard :text="password" class="btn-sm" />
</div>
</template>
</LabeledInput>
</div>
<LabeledInput
v-show="!useRandom"
v-model.trim="confirm"
autocomplete="new-password"
type="password"
label-key="setup.confirmPassword"
/>
v-show="!useRandom"
v-model.trim="confirm"
autocomplete="new-password"
type="password"
label-key="setup.confirmPassword"
/>
<hr class="mt-40 mb-40 " />
<div v-if="mustSetup">
<div class="checkbox mt-40">
<Checkbox v-model="telemetry" :label="t('setup.telemetry.label')" type="checkbox" />
<i v-tooltip="{content:t('setup.telemetry.tip', {}, true), delay: {hide:500}, autoHide: false}" class="icon icon-info" />
</div>
<div class="checkbox pt-10 eula">
<Checkbox v-model="eula" type="checkbox" />
<span v-html="t('setup.eula', {}, true)"></span>
</div>
</div>
<div class="checkbox">
<Checkbox v-model="telemetry" :label="t('setup.telemetry.label')" type="checkbox" />
<i v-tooltip="{content:t('setup.telemetry.tip', {}, true), delay: {hide:500}, autoHide: false}" class="icon icon-info" />
</div>
<div class="checkbox pt-10 eula">
<Checkbox v-model="eula" type="checkbox" />
<span v-html="t('setup.eula', {}, true)"></span>
</div>
<div class="text-center mt-20">
<AsyncButton key="passwordSubmit" type="submit" mode="continue" :disabled="passwordSubmitDisabled" @click="finishPassword" />
</div>
</template>
<div class="text-center mt-20">
<AsyncButton key="passwordSubmit" type="submit" mode="continue" :disabled="passwordSubmitDisabled" @click="finishPassword" />
</div>
</template>
<template v-else>
<p>
<t k="setup.serverUrl.tip" :raw="true" />
</p>
<div class="mt-20">
<LabeledInput v-model="serverUrl" :label="t('setup.serverUrl.label')" />
</div>
<div class="text-center mt-20">
<button type="button" class="btn role-link" @click="done">
{{ t('setup.serverUrl.skip') }}
</button>
<AsyncButton type="submit" mode="continue" @click="setServerUrl" />
</div>
</template>
<template v-else>
<p>
<t k="setup.serverUrl.tip" :raw="true" />
</p>
<div class="mt-20">
<LabeledInput v-model="serverUrl" :label="t('setup.serverUrl.label')" />
<div class="setup-errors mt-20">
<h4 v-for="err in errors" :key="err" class="text-error text-center">
{{ err }}
</h4>
</div>
<div class="text-center mt-20">
<button type="button" class="btn role-link" @click="done">
{{ t('setup.serverUrl.skip') }}
</button>
<AsyncButton type="submit" mode="continue" @click="setServerUrl" />
</div>
</template>
</div>
</div>
<div class="col span-6 landscape" />
@ -275,7 +339,6 @@ export default {
overflow: hidden;
.row {
align-items: center;
& .checkbox {
margin: auto
}
@ -285,12 +348,28 @@ export default {
}
}
.form-col {
display: flex;
flex-direction: column;
& > div:first-of-type {
flex:3;
}
& > div:nth-of-type(2) {
flex: 9;
}
}
.setup-title {
::v-deep code {
font-size: 12px;
padding: 0;
}
}
.setup-errors {
min-height: 50px;
}
p {
line-height: 20px;
}

View File

@ -1,9 +1,10 @@
import { randomStr } from '@/utils/string';
import { parse as parseUrl, removeParam, addParams } from '@/utils/url';
import { findBy, addObjects } from '@/utils/array';
import { GITHUB_NONCE, GITHUB_REDIRECT, GITHUB_SCOPE } from '@/config/query-params';
import { NORMAN } from '@/config/types';
import { addObjects, findBy } from '@/utils/array';
import { openAuthPopup, returnTo } from '@/utils/auth';
import { GITHUB_SCOPE, GITHUB_NONCE, GITHUB_REDIRECT } from '@/config/query-params';
import { base64Encode } from '@/utils/crypto';
import { randomStr } from '@/utils/string';
import { addParams, parse as parseUrl, removeParam } from '@/utils/url';
export const BASE_SCOPES = {
github: ['read:org'],
@ -28,6 +29,8 @@ export const state = function() {
hasAuth: null,
loggedIn: false,
principalId: null,
v3User: null,
initialPass: null,
};
};
@ -48,6 +51,14 @@ export const getters = {
return state.principalId;
},
v3User(state) {
return state.v3User;
},
initialPass(state) {
return state.initialPass;
},
isGithub(state) {
return state.principalId && state.principalId.startsWith('github_user://');
}
@ -58,6 +69,11 @@ export const mutations = {
state.fromHeader = fromHeader;
},
gotUser(state, v3User) {
// Always deference to avoid race condition when setting `mustChangePassword`
state.v3User = { ...v3User };
},
hasAuth(state, hasAuth) {
state.hasAuth = !!hasAuth;
},
@ -75,7 +91,13 @@ export const mutations = {
state.loggedIn = false;
state.principalId = null;
state.v3User = null;
state.initialPass = null;
},
initialPass(state, pass) {
state.initialPass = pass;
}
};
export const actions = {
@ -83,6 +105,27 @@ export const actions = {
commit('gotHeader', fromHeader);
},
async getUser({ dispatch, commit, getters }) {
if (getters.v3User) {
return;
}
const user = await dispatch('rancher/findAll', {
type: NORMAN.USER,
opt: { url: '/v3/users', filter: { me: true } }
}, { root: true });
commit('gotUser', user?.[0]);
},
gotUser({ commit }, user) {
commit('gotUser', user);
},
setInitialPass({ commit }, pass) {
commit('initialPass', pass);
},
getAuthProviders({ dispatch }) {
return dispatch('rancher/findAll', {
type: 'authProvider',