внедрена защищенная смена пароля (бэк + фронт)

This commit is contained in:
max
2026-04-17 19:52:57 +03:00
parent d2e6eb578a
commit b346372555
6 changed files with 240 additions and 0 deletions

View File

@@ -8,4 +8,7 @@ public static class AuthErrors
public static Error IdentityRegistrationDisabled => new("Auth.RegistrationDisabled", "Registration is disabled");
public static Error IdentityUsernameNotUnique => new("Auth.UsernameNotUnique", "Username is already taken");
public static Error UserNotFound => new("Auth.UserNotFound", "User not found");
public static Error PasswordConfirmationMismatch => new("Auth.PasswordConfirmationMismatch", "Passwords do not match");
public static Error PasswordTooShort => new("Auth.PasswordTooShort", "Password must be at least 8 characters");
public static Error OldPasswordInvalid => new("Auth.OldPasswordInvalid", "Current password is incorrect");
}

View File

@@ -0,0 +1,48 @@
using BCrypt.Net;
using Knot.Contracts.Auth.Application.Abstractions;
using Knot.Contracts.Auth.Application.Auth.DTOs;
using Knot.Modules.Auth.Domain;
using Knot.Shared.Kernel;
using Microsoft.EntityFrameworkCore;
namespace Knot.Modules.Auth.Application.Users.ChangePassword;
public sealed record ChangePasswordCommand(
Guid UserId,
string OldPassword,
string NewPassword,
string ConfirmPassword) : ICommand;
internal sealed class ChangePasswordCommandHandler : ICommandHandler<ChangePasswordCommand>
{
private readonly IAuthDbContext _dbContext;
public ChangePasswordCommandHandler(IAuthDbContext dbContext)
{
_dbContext = dbContext;
}
public async Task<Result> Handle(ChangePasswordCommand request, CancellationToken cancellationToken)
{
if (request.NewPassword != request.ConfirmPassword)
return Result.Failure(AuthErrors.PasswordConfirmationMismatch);
if (request.NewPassword.Length < 8)
return Result.Failure(AuthErrors.PasswordTooShort);
var user = await _dbContext.Set<User>()
.FirstOrDefaultAsync(u => u.Id == request.UserId, cancellationToken);
if (user is null)
return Result.Failure(AuthErrors.UserNotFound);
if (!BCrypt.Net.BCrypt.Verify(request.OldPassword, user.PasswordHash))
return Result.Failure(AuthErrors.OldPasswordInvalid);
user.ChangePassword(BCrypt.Net.BCrypt.HashPassword(request.NewPassword));
user.SetRefreshToken(null);
await _dbContext.SaveChangesAsync(cancellationToken);
return Result.Success();
}
}

View File

@@ -2,6 +2,7 @@ using Knot.Shared.Kernel;
using Knot.Modules.Auth.Application.Users.Login;
using Knot.Modules.Auth.Application.Users.Register;
using Knot.Modules.Auth.Application.Users.GetMe;
using Knot.Modules.Auth.Application.Users.ChangePassword;
using MediatR;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
@@ -12,6 +13,8 @@ namespace Knot.Modules.Auth.Presentation.Endpoints;
public static class AuthEndpoints
{
public sealed record ChangePasswordRequest(string OldPassword, string NewPassword, string ConfirmPassword);
public static void MapAuthEndpoints(this WebApplication app)
{
var group = app.MapGroup("api/auth");
@@ -33,5 +36,19 @@ public static class AuthEndpoints
var result = await sender.Send(new GetMeQuery(userContext.UserId), ct);
return result.IsSuccess ? Results.Ok(result.Value) : Results.NotFound();
}).RequireAuthorization();
group.MapPost("change-password", async ([FromBody] ChangePasswordRequest request, ISender sender, IUserContext userContext, CancellationToken ct) =>
{
var result = await sender.Send(
new ChangePasswordCommand(userContext.UserId, request.OldPassword, request.NewPassword, request.ConfirmPassword),
ct);
if (result.IsSuccess) return Results.Ok();
if (result.Error.Code == "Auth.OldPasswordInvalid")
return Results.StatusCode(StatusCodes.Status403Forbidden);
return Results.BadRequest(new { error = result.Error.Code ?? result.Error.Description });
}).RequireAuthorization();
}
}

View File

@@ -62,6 +62,13 @@ export class AuthApi {
return httpClient.request<any>('/config');
}
static async changePassword(data: { oldPassword: string; newPassword: string; confirmPassword: string }) {
return httpClient.request<void>('/auth/change-password', {
method: 'POST',
body: JSON.stringify(data),
});
}
static setToken(token: string | null) {
httpClient.setToken(token);
}

View File

@@ -0,0 +1,140 @@
import { useMemo, useState } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import { Loader2, X } from 'lucide-react';
import { AuthApi } from '../../../auth/infrastructure/authApi';
import { useLang } from '../../../../core/infrastructure/i18n';
interface ChangePasswordModalProps {
open: boolean;
onClose: () => void;
onSuccess: () => void;
}
export default function ChangePasswordModal({ open, onClose, onSuccess }: ChangePasswordModalProps) {
const { lang } = useLang();
const [oldPassword, setOldPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const minLengthOk = newPassword.length >= 8;
const matchOk = newPassword.length > 0 && confirmPassword.length > 0 && newPassword === confirmPassword;
const canSubmit = useMemo(
() => oldPassword.length > 0 && minLengthOk && matchOk && !loading,
[oldPassword.length, minLengthOk, matchOk, loading]
);
const resetState = () => {
setOldPassword('');
setNewPassword('');
setConfirmPassword('');
setError(null);
setLoading(false);
};
const close = () => {
resetState();
onClose();
};
const submit = async () => {
if (!canSubmit) return;
setLoading(true);
setError(null);
try {
await AuthApi.changePassword({ oldPassword, newPassword, confirmPassword });
resetState();
onSuccess();
} catch (e: any) {
const message = e?.message || (lang === 'ru' ? 'Не удалось сменить пароль' : 'Failed to change password');
setError(message);
} finally {
setLoading(false);
}
};
return (
<AnimatePresence>
{open && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[12000] flex items-center justify-center p-4"
>
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" onClick={close} />
<motion.div
initial={{ opacity: 0, y: 12, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 12, scale: 0.98 }}
onClick={(e) => e.stopPropagation()}
className="relative w-full max-w-md rounded-[2rem] border border-white/10 bg-[#111111] p-6 shadow-2xl"
>
<button
onClick={close}
className="absolute right-4 top-4 p-2 rounded-xl text-white/50 hover:text-white hover:bg-white/10"
>
<X size={16} />
</button>
<h3 className="text-lg font-black text-white mb-5">
{lang === 'ru' ? 'Смена пароля' : 'Change password'}
</h3>
<div className="space-y-3">
<input
type="password"
value={oldPassword}
onChange={(e) => setOldPassword(e.target.value)}
placeholder={lang === 'ru' ? 'Текущий пароль' : 'Current password'}
className="w-full bg-white/5 border border-white/10 rounded-2xl px-4 py-3 text-sm text-white outline-none focus:border-primary/60"
/>
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder={lang === 'ru' ? 'Новый пароль' : 'New password'}
className="w-full bg-white/5 border border-white/10 rounded-2xl px-4 py-3 text-sm text-white outline-none focus:border-primary/60"
/>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder={lang === 'ru' ? 'Подтвердите пароль' : 'Confirm password'}
className="w-full bg-white/5 border border-white/10 rounded-2xl px-4 py-3 text-sm text-white outline-none focus:border-primary/60"
/>
</div>
<div className="mt-3 space-y-1">
<p className={`text-xs ${minLengthOk ? 'text-emerald-400' : 'text-white/45'}`}>
{lang === 'ru' ? 'Минимум 8 символов' : 'At least 8 characters'}
</p>
<p className={`text-xs ${matchOk ? 'text-emerald-400' : 'text-white/45'}`}>
{lang === 'ru' ? 'Пароли совпадают' : 'Passwords match'}
</p>
</div>
{error && <p className="mt-3 text-sm text-red-400">{error}</p>}
<div className="mt-6 flex items-center justify-end gap-2">
<button
onClick={close}
className="px-4 py-2 rounded-xl text-xs font-black uppercase tracking-wider text-white/60 hover:text-white"
>
{lang === 'ru' ? 'Отмена' : 'Cancel'}
</button>
<button
onClick={submit}
disabled={!canSubmit}
className="px-5 py-2 rounded-xl bg-primary text-on-primary text-xs font-black uppercase tracking-wider disabled:opacity-40 disabled:cursor-not-allowed"
>
{loading ? <Loader2 size={14} className="animate-spin" /> : (lang === 'ru' ? 'Сменить' : 'Change')}
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}

View File

@@ -15,6 +15,7 @@ import AvatarCropModal from './AvatarCropModal';
import { Area } from 'react-easy-crop';
import { getCroppedImg } from '../../../../core/utils/cropImage';
import EmojiOnlyPicker from '../../../stories/presentation/components/EmojiOnlyPicker';
import ChangePasswordModal from './ChangePasswordModal';
const BIO_MAX_LENGTH = 200;
const STATUS_MAX_LENGTH = 50;
@@ -40,6 +41,7 @@ export default function SettingsPage() {
});
const [saving, setSaving] = useState(false);
const [showStatusPicker, setShowStatusPicker] = useState(false);
const [showChangePasswordModal, setShowChangePasswordModal] = useState(false);
// Avatar cropping state
const [cropModal, setCropModal] = useState<{
@@ -410,6 +412,20 @@ export default function SettingsPage() {
{/* Account Section */}
<div className="p-6 rounded-3xl bg-white/[0.02] border border-white/5 flex flex-col justify-between">
<button
onClick={() => setShowChangePasswordModal(true)}
className="w-full mb-3 flex items-center justify-between p-4 rounded-2xl bg-white/5 hover:bg-white/10 border border-white/10 transition-all group"
>
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-xl bg-white/10 flex items-center justify-center text-primary group-hover:scale-105 transition-transform">
<Shield size={16} />
</div>
<span className="text-[11px] font-black uppercase tracking-widest text-white/70 group-hover:text-white transition-colors">
{lang === 'ru' ? 'Сменить пароль' : 'Change password'}
</span>
</div>
<ChevronRight size={16} className="text-white/30" />
</button>
<button
onClick={() => { logout(); }}
className="w-full flex items-center justify-between p-4 rounded-2xl bg-red-500/5 hover:bg-red-500/15 border border-red-500/10 transition-all group"
@@ -447,6 +463,15 @@ export default function SettingsPage() {
/>
)}
</AnimatePresence>
<ChangePasswordModal
open={showChangePasswordModal}
onClose={() => setShowChangePasswordModal(false)}
onSuccess={() => {
setShowChangePasswordModal(false);
// Access token remains valid; ask user to re-login to ensure security on all devices.
logout();
}}
/>
</div>
);
}