mirror of
https://github.com/EasyTier/EasyTier.git
synced 2026-04-23 00:27:06 +08:00
feat(web): warn on default-password accounts
Track built-in admin and user accounts that still use their seeded password so the web UI can prompt operators to rotate credentials after deployment. - Persist must-change-password state for seeded accounts. - Clear the reminder after password changes and validate empty-password updates. - Keep the migration and auth API behavior explicit.
This commit is contained in:
@@ -286,6 +286,9 @@ web:
|
||||
logout: 退出登录
|
||||
language: 语言
|
||||
change_password: 修改密码
|
||||
change_password_now: 立即修改密码
|
||||
default_password_warning: 当前账号仍在使用系统默认密码。为保障安全,请部署完成后立即修改密码。
|
||||
password_changed_relogin: 密码已修改,请重新登录。
|
||||
|
||||
device:
|
||||
list: 设备列表
|
||||
@@ -369,6 +372,7 @@ web:
|
||||
change_password: 修改密码
|
||||
old_password: 旧密码
|
||||
new_password: 新密码
|
||||
new_password_empty: 新密码不能为空
|
||||
confirm_password: 确认新密码
|
||||
language: 语言
|
||||
theme: 主题
|
||||
|
||||
@@ -286,6 +286,9 @@ web:
|
||||
logout: Logout
|
||||
language: Language
|
||||
change_password: Change Password
|
||||
change_password_now: Change Password Now
|
||||
default_password_warning: This account is still using the default password. Change it immediately after deployment to keep your instance secure.
|
||||
password_changed_relogin: Password changed. Please log in again.
|
||||
|
||||
device:
|
||||
list: Device List
|
||||
@@ -369,6 +372,7 @@ web:
|
||||
change_password: Change Password
|
||||
old_password: Old Password
|
||||
new_password: New Password
|
||||
new_password_empty: New password cannot be empty
|
||||
confirm_password: Confirm New Password
|
||||
language: Language
|
||||
theme: Theme
|
||||
|
||||
@@ -1,17 +1,52 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, inject, ref } from 'vue';
|
||||
import { Card, Password, Button } from 'primevue';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import ApiClient from '../modules/api';
|
||||
import { clearMustChangePasswordFlag } from '../modules/auth-status';
|
||||
|
||||
const dialogRef = inject<any>('dialogRef');
|
||||
|
||||
const api = computed<ApiClient>(() => dialogRef.value.data.api);
|
||||
|
||||
const password = ref('');
|
||||
const toast = useToast();
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
const passwordIsEmpty = computed(() => password.value.trim().length === 0);
|
||||
|
||||
const changePassword = async () => {
|
||||
await api.value.change_password(password.value);
|
||||
dialogRef.value.close();
|
||||
if (passwordIsEmpty.value) {
|
||||
toast.add({
|
||||
severity: 'warn',
|
||||
summary: t('web.common.warning'),
|
||||
detail: t('web.settings.new_password_empty'),
|
||||
life: 3000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await api.value.change_password(password.value);
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('web.common.success'),
|
||||
detail: t('web.main.password_changed_relogin'),
|
||||
life: 3000,
|
||||
});
|
||||
clearMustChangePasswordFlag();
|
||||
dialogRef.value.close();
|
||||
router.push({ name: 'login' });
|
||||
} catch (error) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('web.common.error'),
|
||||
detail: error instanceof Error ? error.message : String(error),
|
||||
life: 3000,
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -19,15 +54,17 @@ const changePassword = async () => {
|
||||
<div class="flex items-center justify-center">
|
||||
<Card class="w-full max-w-md p-6">
|
||||
<template #header>
|
||||
<h2 class="text-2xl font-semibold text-center">Change Password
|
||||
<h2 class="text-2xl font-semibold text-center">{{ t('web.main.change_password') }}
|
||||
</h2>
|
||||
</template>
|
||||
<template #content>
|
||||
<div class="flex flex-col space-y-4">
|
||||
<Password v-model="password" placeholder="New Password" :feedback="false" toggleMask />
|
||||
<Button @click="changePassword" label="Ok" />
|
||||
<Password v-model="password" :placeholder="t('web.settings.new_password')" :feedback="false"
|
||||
toggleMask />
|
||||
<Button @click="changePassword" :label="t('web.common.confirm')"
|
||||
:disabled="passwordIsEmpty" />
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { I18nUtils } from 'easytier-frontend-lib';
|
||||
import { getInitialApiHost, cleanAndLoadApiHosts, saveApiHost } from "../modules/api-host"
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import ApiClient, { Credential, RegisterData } from '../modules/api';
|
||||
import { setMustChangePasswordFlag } from '../modules/auth-status';
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -33,6 +34,7 @@ const onSubmit = async () => {
|
||||
let ret = await api.value?.login(credential);
|
||||
if (ret.success) {
|
||||
localStorage.setItem('apiHost', btoa(apiHost.value));
|
||||
setMustChangePasswordFlag(Boolean(ret.mustChangePassword));
|
||||
router.push({
|
||||
name: 'dashboard',
|
||||
params: { apiHost: btoa(apiHost.value) },
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { I18nUtils } from 'easytier-frontend-lib'
|
||||
import { computed, onMounted, ref, onUnmounted, nextTick } from 'vue';
|
||||
import { Button, TieredMenu } from 'primevue';
|
||||
import { Button, Message, TieredMenu } from 'primevue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useDialog } from 'primevue/usedialog';
|
||||
import ChangePassword from './ChangePassword.vue';
|
||||
import Icon from '../assets/easytier.png'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import ApiClient from '../modules/api';
|
||||
import {
|
||||
clearMustChangePasswordFlag,
|
||||
getMustChangePasswordFlag,
|
||||
setMustChangePasswordFlag,
|
||||
} from '../modules/auth-status';
|
||||
|
||||
const { t } = useI18n()
|
||||
const route = useRoute();
|
||||
@@ -15,6 +20,7 @@ const router = useRouter();
|
||||
const api = computed<ApiClient | undefined>(() => {
|
||||
try {
|
||||
return new ApiClient(atob(route.params.apiHost as string), () => {
|
||||
clearMustChangePasswordFlag();
|
||||
router.push({ name: 'login' });
|
||||
})
|
||||
} catch (e) {
|
||||
@@ -23,25 +29,42 @@ const api = computed<ApiClient | undefined>(() => {
|
||||
});
|
||||
|
||||
const dialog = useDialog();
|
||||
const mustChangePassword = ref(false);
|
||||
|
||||
const openChangePasswordDialog = () => {
|
||||
dialog.open(ChangePassword, {
|
||||
props: {
|
||||
modal: true,
|
||||
},
|
||||
data: {
|
||||
api: api.value,
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const loadAuthStatus = async () => {
|
||||
const cachedStatus = getMustChangePasswordFlag();
|
||||
if (cachedStatus !== null) {
|
||||
mustChangePassword.value = cachedStatus;
|
||||
}
|
||||
|
||||
try {
|
||||
const status = await api.value?.check_login_status();
|
||||
mustChangePassword.value = Boolean(
|
||||
status?.loggedIn && status?.mustChangePassword,
|
||||
);
|
||||
setMustChangePasswordFlag(mustChangePassword.value);
|
||||
} catch (e) {
|
||||
console.error('Failed to load auth status', e);
|
||||
}
|
||||
};
|
||||
|
||||
const userMenu = ref();
|
||||
const userMenuItems = ref([
|
||||
{
|
||||
label: t('web.main.change_password'),
|
||||
icon: 'pi pi-key',
|
||||
command: () => {
|
||||
console.log('File');
|
||||
let ret = dialog.open(ChangePassword, {
|
||||
props: {
|
||||
modal: true,
|
||||
},
|
||||
data: {
|
||||
api: api.value,
|
||||
}
|
||||
});
|
||||
|
||||
console.log("return", ret)
|
||||
},
|
||||
command: openChangePasswordDialog,
|
||||
},
|
||||
{
|
||||
label: t('web.main.logout'),
|
||||
@@ -52,6 +75,7 @@ const userMenuItems = ref([
|
||||
} catch (e) {
|
||||
console.error("logout failed", e);
|
||||
}
|
||||
clearMustChangePasswordFlag();
|
||||
router.push({ name: 'login' });
|
||||
},
|
||||
},
|
||||
@@ -92,6 +116,7 @@ onMounted(async () => {
|
||||
// 等待 DOM 渲染完成后添加事件监听器
|
||||
await nextTick();
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
await loadAuthStatus();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
@@ -171,6 +196,13 @@ onUnmounted(() => {
|
||||
<div class="p-4 sm:ml-64">
|
||||
<div class="p-4 border-2 border-gray-200 border-dashed rounded-lg dark:border-gray-700">
|
||||
<div class="grid grid-cols-1 gap-4">
|
||||
<Message v-if="mustChangePassword" severity="warn" :closable="false">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<span>{{ t('web.main.default_password_warning') }}</span>
|
||||
<Button size="small" icon="pi pi-key" :label="t('web.main.change_password_now')"
|
||||
@click="openChangePasswordDialog" />
|
||||
</div>
|
||||
</Message>
|
||||
<RouterView v-slot="{ Component }">
|
||||
<component :is="Component" :api="api" />
|
||||
</RouterView>
|
||||
|
||||
@@ -2,6 +2,8 @@ import axios, { AxiosError, AxiosInstance, AxiosResponse, InternalAxiosRequestCo
|
||||
import { type Api, NetworkTypes, Utils } from 'easytier-frontend-lib';
|
||||
import { Md5 } from 'ts-md5';
|
||||
|
||||
const hashAuthPassword = (password: string) => Md5.hashStr(password);
|
||||
|
||||
export interface ValidateConfigResponse {
|
||||
toml_config: string;
|
||||
}
|
||||
@@ -14,6 +16,16 @@ export interface OidcConfigResponse {
|
||||
export interface LoginResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
mustChangePassword?: boolean;
|
||||
}
|
||||
|
||||
export interface AuthStatusResponse {
|
||||
must_change_password: boolean;
|
||||
}
|
||||
|
||||
export interface CheckLoginStatusResponse {
|
||||
loggedIn: boolean;
|
||||
mustChangePassword: boolean;
|
||||
}
|
||||
|
||||
export interface RegisterResponse {
|
||||
@@ -82,7 +94,6 @@ export class ApiClient {
|
||||
|
||||
// 添加响应拦截器
|
||||
this.client.interceptors.response.use((response: AxiosResponse) => {
|
||||
console.debug('Axios Response:', response);
|
||||
return response.data; // 假设服务器返回的数据都在data属性中
|
||||
}, (error: any) => {
|
||||
if (error.response) {
|
||||
@@ -108,9 +119,8 @@ export class ApiClient {
|
||||
// 注册
|
||||
public async register(data: RegisterData): Promise<RegisterResponse> {
|
||||
try {
|
||||
data.credentials.password = Md5.hashStr(data.credentials.password);
|
||||
const response = await this.client.post<RegisterResponse>('/auth/register', data);
|
||||
console.log("register response:", response);
|
||||
data.credentials.password = hashAuthPassword(data.credentials.password);
|
||||
await this.client.post<RegisterResponse>('/auth/register', data);
|
||||
return { success: true, message: 'Register success', };
|
||||
} catch (error) {
|
||||
if (error instanceof AxiosError) {
|
||||
@@ -123,10 +133,13 @@ export class ApiClient {
|
||||
// 登录
|
||||
public async login(data: Credential): Promise<LoginResponse> {
|
||||
try {
|
||||
data.password = Md5.hashStr(data.password);
|
||||
const response = await this.client.post<any>('/auth/login', data);
|
||||
console.log("login response:", response);
|
||||
return { success: true, message: 'Login success', };
|
||||
data.password = hashAuthPassword(data.password);
|
||||
const response = await this.client.post<any, AuthStatusResponse>('/auth/login', data);
|
||||
return {
|
||||
success: true,
|
||||
message: 'Login success',
|
||||
mustChangePassword: response.must_change_password,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof AxiosError) {
|
||||
if (error.response?.status === 401) {
|
||||
@@ -147,16 +160,15 @@ export class ApiClient {
|
||||
}
|
||||
|
||||
public async change_password(new_password: string) {
|
||||
await this.client.put('/auth/password', { new_password: Md5.hashStr(new_password) });
|
||||
await this.client.put('/auth/password', { new_password: hashAuthPassword(new_password) });
|
||||
}
|
||||
|
||||
public async check_login_status() {
|
||||
try {
|
||||
await this.client.get('/auth/check_login_status');
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
public async check_login_status(): Promise<CheckLoginStatusResponse> {
|
||||
const response = await this.client.get<any, AuthStatusResponse>('/auth/check_login_status');
|
||||
return {
|
||||
loggedIn: true,
|
||||
mustChangePassword: response.must_change_password,
|
||||
};
|
||||
}
|
||||
|
||||
public async list_session() {
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
const MUST_CHANGE_PASSWORD_STORAGE_KEY = 'auth.mustChangePassword';
|
||||
|
||||
export const getMustChangePasswordFlag = (): boolean | null => {
|
||||
const value = sessionStorage.getItem(MUST_CHANGE_PASSWORD_STORAGE_KEY);
|
||||
if (value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return value === 'true';
|
||||
};
|
||||
|
||||
export const setMustChangePasswordFlag = (value: boolean) => {
|
||||
sessionStorage.setItem(MUST_CHANGE_PASSWORD_STORAGE_KEY, value ? 'true' : 'false');
|
||||
};
|
||||
|
||||
export const clearMustChangePasswordFlag = () => {
|
||||
sessionStorage.removeItem(MUST_CHANGE_PASSWORD_STORAGE_KEY);
|
||||
};
|
||||
@@ -11,6 +11,7 @@ pub struct Model {
|
||||
#[sea_orm(unique)]
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
pub must_change_password: bool,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
|
||||
@@ -96,6 +96,7 @@ impl Db {
|
||||
let user_active = users::ActiveModel {
|
||||
username: Set(username.to_string()),
|
||||
password: Set(password_hash),
|
||||
must_change_password: Set(false),
|
||||
..Default::default()
|
||||
};
|
||||
let insert_result = users::Entity::insert(user_active).exec(&txn).await?;
|
||||
@@ -280,7 +281,28 @@ mod tests {
|
||||
use easytier::{proto::api::manage::NetworkConfig, rpc_service::remote_client::Storage};
|
||||
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter as _};
|
||||
|
||||
use crate::db::{entity::user_running_network_configs, Db, ListNetworkProps};
|
||||
use crate::db::{
|
||||
entity::{user_running_network_configs, users},
|
||||
Db, ListNetworkProps,
|
||||
};
|
||||
|
||||
#[tokio::test]
|
||||
async fn created_users_default_to_not_requiring_password_change() {
|
||||
let db = Db::memory_db().await;
|
||||
|
||||
let user = db
|
||||
.create_user_and_join_users_group("created-user", "pre-hashed-password".to_string())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let stored = users::Entity::find_by_id(user.id)
|
||||
.one(db.orm_db())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
assert!(!stored.must_change_password);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_user_network_config_management() {
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
pub struct Migration;
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum Users {
|
||||
Table,
|
||||
Username,
|
||||
MustChangePassword,
|
||||
}
|
||||
|
||||
impl MigrationName for Migration {
|
||||
fn name(&self) -> &str {
|
||||
"m20260405_000003_add_must_change_password"
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(Users::Table)
|
||||
.add_column(
|
||||
ColumnDef::new(Users::MustChangePassword)
|
||||
.boolean()
|
||||
.not_null()
|
||||
.default(false),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.exec_stmt(
|
||||
Query::update()
|
||||
.table(Users::Table)
|
||||
.value(Users::MustChangePassword, true)
|
||||
.and_where(Expr::col(Users::Username).is_in(["admin", "user"]))
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(Users::Table)
|
||||
.drop_column(Users::MustChangePassword)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ use sea_orm_migration::prelude::*;
|
||||
|
||||
mod m20241029_000001_init;
|
||||
mod m20260403_000002_scope_network_config_unique;
|
||||
mod m20260405_000003_add_must_change_password;
|
||||
|
||||
pub struct Migrator;
|
||||
|
||||
@@ -11,6 +12,7 @@ impl MigratorTrait for Migrator {
|
||||
vec![
|
||||
Box::new(m20241029_000001_init::Migration),
|
||||
Box::new(m20260403_000002_scope_network_config_unique::Migration),
|
||||
Box::new(m20260405_000003_add_must_change_password::Migration),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,7 @@ use axum::{
|
||||
Router,
|
||||
};
|
||||
use axum_login::login_required;
|
||||
use axum_messages::Message;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::restful::users::Backend;
|
||||
|
||||
@@ -18,9 +17,9 @@ use super::{
|
||||
AppStateInner,
|
||||
};
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct LoginResult {
|
||||
messages: Vec<Message>,
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct AuthStatusResponse {
|
||||
must_change_password: bool,
|
||||
}
|
||||
|
||||
pub fn router() -> Router<AppStateInner> {
|
||||
@@ -40,12 +39,15 @@ pub fn router() -> Router<AppStateInner> {
|
||||
}
|
||||
|
||||
mod put {
|
||||
use crate::restful::{
|
||||
other_error,
|
||||
users::{ChangePassword, ChangePasswordError},
|
||||
HttpHandleError,
|
||||
};
|
||||
use axum::Json;
|
||||
use axum_login::AuthUser;
|
||||
use easytier::proto::common::Void;
|
||||
|
||||
use crate::restful::{other_error, users::ChangePassword, HttpHandleError};
|
||||
|
||||
use super::*;
|
||||
|
||||
pub async fn change_password(
|
||||
@@ -58,15 +60,18 @@ mod put {
|
||||
.await
|
||||
{
|
||||
tracing::error!("Failed to change password: {:?}", e);
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json::from(other_error(format!("{:?}", e))),
|
||||
));
|
||||
let status = match e {
|
||||
ChangePasswordError::EmptyPassword => StatusCode::BAD_REQUEST,
|
||||
ChangePasswordError::UserNotFound | ChangePasswordError::Db(_) => {
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
}
|
||||
};
|
||||
return Err((status, Json::from(other_error(format!("{:?}", e)))));
|
||||
}
|
||||
|
||||
let _ = auth_session.logout().await;
|
||||
|
||||
Ok(Void::default().into())
|
||||
Ok(Json(Void::default()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,7 +91,7 @@ mod post {
|
||||
pub async fn login(
|
||||
mut auth_session: AuthSession,
|
||||
Json(creds): Json<Credentials>,
|
||||
) -> Result<Json<Void>, HttpHandleError> {
|
||||
) -> Result<Json<AuthStatusResponse>, HttpHandleError> {
|
||||
let user = match auth_session.authenticate(creds.clone()).await {
|
||||
Ok(Some(user)) => user,
|
||||
Ok(None) => {
|
||||
@@ -110,7 +115,9 @@ mod post {
|
||||
));
|
||||
}
|
||||
|
||||
Ok(Void::default().into())
|
||||
Ok(Json(AuthStatusResponse {
|
||||
must_change_password: user.db_user.must_change_password,
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn register(
|
||||
@@ -189,9 +196,11 @@ mod get {
|
||||
|
||||
pub async fn check_login_status(
|
||||
auth_session: AuthSession,
|
||||
) -> Result<Json<Void>, HttpHandleError> {
|
||||
if auth_session.user.is_some() {
|
||||
Ok(Json(Void::default()))
|
||||
) -> Result<Json<AuthStatusResponse>, HttpHandleError> {
|
||||
if let Some(user) = auth_session.user {
|
||||
Ok(Json(AuthStatusResponse {
|
||||
must_change_password: user.db_user.must_change_password,
|
||||
}))
|
||||
} else {
|
||||
Err((
|
||||
StatusCode::UNAUTHORIZED,
|
||||
|
||||
@@ -12,6 +12,8 @@ use tokio::task;
|
||||
|
||||
use crate::db::{self, entity};
|
||||
|
||||
const EMPTY_PASSWORD_MD5: &str = "d41d8cd98f00b204e9800998ecf8427e";
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct User {
|
||||
pub(crate) db_user: entity::users::Model,
|
||||
@@ -64,6 +66,18 @@ pub struct ChangePassword {
|
||||
pub new_password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ChangePasswordError {
|
||||
#[error("Password cannot be empty")]
|
||||
EmptyPassword,
|
||||
|
||||
#[error("User not found")]
|
||||
UserNotFound,
|
||||
|
||||
#[error(transparent)]
|
||||
Db(#[from] sea_orm::DbErr),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Backend {
|
||||
db: db::Db,
|
||||
@@ -119,7 +133,14 @@ impl Backend {
|
||||
&self,
|
||||
id: <User as AuthUser>::Id,
|
||||
req: &ChangePassword,
|
||||
) -> anyhow::Result<()> {
|
||||
) -> Result<(), ChangePasswordError> {
|
||||
// With the existing pre-hashed protocol the backend can only reject the
|
||||
// exact empty-string digest; whitespace-only passwords must be blocked
|
||||
// on the client before hashing.
|
||||
if req.new_password == EMPTY_PASSWORD_MD5 {
|
||||
return Err(ChangePasswordError::EmptyPassword);
|
||||
}
|
||||
|
||||
let hashed_password = password_auth::generate_hash(req.new_password.as_str());
|
||||
|
||||
use entity::users;
|
||||
@@ -127,9 +148,10 @@ impl Backend {
|
||||
let mut user = users::Entity::find_by_id(id)
|
||||
.one(self.db.orm_db())
|
||||
.await?
|
||||
.ok_or(anyhow::anyhow!("User not found"))?
|
||||
.ok_or(ChangePasswordError::UserNotFound)?
|
||||
.into_active_model();
|
||||
user.password = Set(hashed_password.clone());
|
||||
user.must_change_password = Set(false);
|
||||
|
||||
entity::users::Entity::update(user)
|
||||
.exec(self.db.orm_db())
|
||||
@@ -242,6 +264,107 @@ impl AuthzBackend for Backend {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use axum_login::AuthnBackend;
|
||||
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter as _};
|
||||
|
||||
use super::{Backend, ChangePassword, ChangePasswordError, EMPTY_PASSWORD_MD5};
|
||||
use crate::db::{entity::users, Db};
|
||||
|
||||
async fn find_user(db: &Db, username: &str) -> users::Model {
|
||||
users::Entity::find()
|
||||
.filter(users::Column::Username.eq(username))
|
||||
.one(db.orm_db())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn seeded_default_users_require_password_change() {
|
||||
let db = Db::memory_db().await;
|
||||
|
||||
assert!(find_user(&db, "admin").await.must_change_password);
|
||||
assert!(find_user(&db, "user").await.must_change_password);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn auto_created_user_does_not_require_password_change() {
|
||||
let db = Db::memory_db().await;
|
||||
|
||||
db.auto_create_user("oidc-user").await.unwrap();
|
||||
|
||||
assert!(!find_user(&db, "oidc-user").await.must_change_password);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn change_password_clears_must_change_password_flag() {
|
||||
let db = Db::memory_db().await;
|
||||
let backend = Backend::new(db.clone());
|
||||
let admin = find_user(&db, "admin").await;
|
||||
|
||||
backend
|
||||
.change_password(
|
||||
admin.id,
|
||||
&ChangePassword {
|
||||
new_password: "f1086f68460b65771de50a970cd1242d".to_string(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(!find_user(&db, "admin").await.must_change_password);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn change_password_rejects_empty_password_digest() {
|
||||
let db = Db::memory_db().await;
|
||||
let backend = Backend::new(db.clone());
|
||||
let admin = find_user(&db, "admin").await;
|
||||
|
||||
let error = backend
|
||||
.change_password(
|
||||
admin.id,
|
||||
&ChangePassword {
|
||||
new_password: EMPTY_PASSWORD_MD5.to_string(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert!(matches!(error, ChangePasswordError::EmptyPassword));
|
||||
assert!(find_user(&db, "admin").await.must_change_password);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn can_authenticate_with_new_password_after_change() {
|
||||
let db = Db::memory_db().await;
|
||||
let backend = Backend::new(db.clone());
|
||||
let admin = find_user(&db, "admin").await;
|
||||
|
||||
backend
|
||||
.change_password(
|
||||
admin.id,
|
||||
&ChangePassword {
|
||||
new_password: "f1086f68460b65771de50a970cd1242d".to_string(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let authenticated = backend
|
||||
.authenticate(super::Credentials {
|
||||
username: "admin".to_string(),
|
||||
password: "f1086f68460b65771de50a970cd1242d".to_string(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(authenticated.is_some());
|
||||
}
|
||||
}
|
||||
|
||||
// We use a type alias for convenience.
|
||||
//
|
||||
// Note that we've supplied our concrete backend here.
|
||||
|
||||
Reference in New Issue
Block a user