внедрена защищенная смена пароля (бэк + фронт)
This commit is contained in:
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user