diff --git a/account-ui/src/components/PasskeyCard.tsx b/account-ui/src/components/PasskeyCard.tsx index 35dc561..2e2aead 100644 --- a/account-ui/src/components/PasskeyCard.tsx +++ b/account-ui/src/components/PasskeyCard.tsx @@ -8,6 +8,7 @@ import Card from './Card'; import Button from './Button'; import Alert from './Alert'; import StatusDot from './StatusDot'; +import PasswordPrompt from './PasswordPrompt'; const PasskeyCard: React.FC = () => { const { data: passkeys, refetch } = useQuery({ @@ -18,12 +19,15 @@ const PasskeyCard: React.FC = () => { const [addError, setAddError] = useState(''); const [deleteError, setDeleteError] = useState(''); const [isAdding, setIsAdding] = useState(false); + const [showAddPrompt, setShowAddPrompt] = useState(false); + const [showDeletePrompt, setShowDeletePrompt] = useState(null); - const handleAdd = async () => { + const handleAdd = async (password: string) => { setAddError(''); setIsAdding(true); + setShowAddPrompt(false); try { - const beginRes = await api.post('/passkeys/register/begin'); + const beginRes = await api.post('/passkeys/register/begin', { current_password: password }); const data = beginRes.data.data; const credential = await performPasskeyRegistration(data); await api.post(`/passkeys/register/finish?challenge_id=${data.challenge_id}`, credential); @@ -39,10 +43,13 @@ const PasskeyCard: React.FC = () => { } }; - const handleDelete = async (id: string) => { + const handleDelete = async (password: string) => { + if (!showDeletePrompt) return; setDeleteError(''); + const id = showDeletePrompt; + setShowDeletePrompt(null); try { - await api.delete(`/passkeys/${id}`); + await api.delete(`/passkeys/${id}`, { data: { current_password: password } }); refetch(); } catch (err: unknown) { setDeleteError(extractError(err, 'Failed to delete passkey.')); @@ -50,49 +57,69 @@ const PasskeyCard: React.FC = () => { }; return ( - - {isAdding ? 'Registering…' : 'Add Passkey'} - - } - > - {addError && } - {deleteError && } - {passkeys && passkeys.length > 0 ? ( -
- {passkeys.map((pk: { id: string; name: string; created_at: string }) => ( -
-
-
- -
-
-

{pk.name || 'Unnamed Passkey'}

-

- Added {new Date(pk.created_at).toLocaleDateString()} -

+ <> + {showAddPrompt && ( + setShowAddPrompt(false)} + /> + )} + {showDeletePrompt && ( + setShowDeletePrompt(null)} + /> + )} + setShowAddPrompt(true)} disabled={isAdding}> + {isAdding ? 'Registering…' : 'Add Passkey'} + + } + > + {addError && } + {deleteError && } + {passkeys && passkeys.length > 0 ? ( +
+ {passkeys.map((pk: { id: string; name: string; created_at: string }) => ( +
+
+
+ +
+
+

{pk.name || 'Unnamed Passkey'}

+

+ Added {new Date(pk.created_at).toLocaleDateString()} +

+
+
- -
- ))} -
- ) : ( -
- - No passkeys registered -
- )} - + ))} +
+ ) : ( +
+ + No passkeys registered +
+ )} + + ); }; diff --git a/account-ui/src/components/PasswordPrompt.tsx b/account-ui/src/components/PasswordPrompt.tsx new file mode 100644 index 0000000..3e3a2f5 --- /dev/null +++ b/account-ui/src/components/PasswordPrompt.tsx @@ -0,0 +1,70 @@ +import React, { useState } from 'react'; +import Modal from './Modal'; +import Button from './Button'; +import Alert from './Alert'; + +interface PasswordPromptProps { + title: string; + message: string; + confirmLabel?: string; + onConfirm: (password: string) => Promise; + onCancel: () => void; +} + +const PasswordPrompt: React.FC = ({ + title, + message, + confirmLabel = 'Confirm', + onConfirm, + onCancel, +}) => { + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + setIsSubmitting(true); + try { + await onConfirm(password); + } catch (err: unknown) { + if (err instanceof Error) { + setError(err.message); + } else { + setError('An error occurred.'); + } + } finally { + setIsSubmitting(false); + } + }; + + return ( + +
+

{message}

+
+ + setPassword(e.target.value)} + autoFocus + /> +
+ {error && } +
+ + +
+ +
+ ); +}; + +export default PasswordPrompt; diff --git a/account-ui/src/components/TotpSetup.tsx b/account-ui/src/components/TotpSetup.tsx index 7e47293..05e13e5 100644 --- a/account-ui/src/components/TotpSetup.tsx +++ b/account-ui/src/components/TotpSetup.tsx @@ -13,26 +13,34 @@ interface TotpSetupModalProps { } const TotpSetupModal: React.FC = ({ onClose, onSuccess }) => { - const [step, setStep] = useState<'loading' | 'qr' | 'verify'>('loading'); + const [step, setStep] = useState<'password' | 'loading' | 'qr' | 'verify'>('password'); const [secret, setSecret] = useState(''); const [qrData, setQrData] = useState(''); const [code, setCode] = useState(''); + const [password, setPassword] = useState(''); const [copied, setCopied] = useState(false); const [error, setError] = useState(''); const [isVerifying, setIsVerifying] = useState(false); - React.useEffect(() => { - api.post('/mfa/totp/setup') + const beginSetup = (pw: string) => { + setStep('loading'); + setError(''); + api.post('/mfa/totp/setup', { current_password: pw }) .then((res) => { setSecret(res.data.data.secret); setQrData(res.data.data.qr_code_data); setStep('qr'); }) - .catch(() => { - setError('Failed to initialize TOTP setup'); - setStep('qr'); + .catch((err) => { + setError(extractError(err, 'Failed to initialize TOTP setup')); + setStep('password'); }); - }, []); + }; + + const handlePasswordSubmit = (e: React.FormEvent) => { + e.preventDefault(); + beginSetup(password); + }; const handleCopy = () => { navigator.clipboard.writeText(secret).then(() => { @@ -58,6 +66,33 @@ const TotpSetupModal: React.FC = ({ onClose, onSuccess }) = return ( + {step === 'password' && ( +
+

+ Enter your password to set up two-factor authentication. +

+
+ + setPassword(e.target.value)} + autoFocus + /> +
+ {error && } +
+ + +
+ + )} + {step === 'loading' && (
diff --git a/account-ui/src/pages/Profile.tsx b/account-ui/src/pages/Profile.tsx index 57fa075..2d6c720 100644 --- a/account-ui/src/pages/Profile.tsx +++ b/account-ui/src/pages/Profile.tsx @@ -4,6 +4,7 @@ import api from '../api'; import Card from '../components/Card'; import Button from '../components/Button'; import Alert from '../components/Alert'; +import PasswordPrompt from '../components/PasswordPrompt'; import { useSettings } from '../context/SettingsContext'; import { extractError } from '../lib/utils'; @@ -67,6 +68,8 @@ const ProfilePage: React.FC = () => { const [isUpdating, setIsUpdating] = useState(false); const [error, setError] = useState(''); const [success, setSuccess] = useState(''); + const [showPasswordPrompt, setShowPasswordPrompt] = useState(false); + const [pendingPayload, setPendingPayload] = useState | null>(null); useEffect(() => { if (profile) { @@ -96,15 +99,16 @@ const ProfilePage: React.FC = () => { const set = (key: keyof ProfileForm) => (e: React.ChangeEvent) => setForm((f) => ({ ...f, [key]: e.target.value })); - const handleUpdate = async (e: React.FormEvent) => { - e.preventDefault(); - setError(''); - setSuccess(''); + const needsPasswordConfirm = (payload: Record) => { + if (!profile) return false; + const emailChanging = payload.email && payload.email !== profile.email; + const usernameChanging = payload.username && payload.username !== profile.username; + return emailChanging || usernameChanging; + }; + + const submitProfile = async (payload: Record) => { setIsUpdating(true); try { - const payload = { ...form }; - if (!settings.allow_username_change) delete (payload as Partial).username; - if (!settings.allow_email_change) delete (payload as Partial).email; await api.put('/profile', payload); setSuccess('Profile updated successfully.'); refetch(); @@ -115,6 +119,30 @@ const ProfilePage: React.FC = () => { } }; + const handleUpdate = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + setSuccess(''); + const payload: Record = { ...form }; + if (!settings.allow_username_change) delete payload.username; + if (!settings.allow_email_change) delete payload.email; + + if (needsPasswordConfirm(payload)) { + setPendingPayload(payload); + setShowPasswordPrompt(true); + return; + } + + await submitProfile(payload); + }; + + const handlePasswordConfirm = async (password: string) => { + if (!pendingPayload) return; + setShowPasswordPrompt(false); + await submitProfile({ ...pendingPayload, current_password: password }); + setPendingPayload(null); + }; + const showGivenName = settings.profile_field_given_name !== 'hidden'; const showFamilyName = settings.profile_field_family_name !== 'hidden'; const showMiddleName = settings.profile_field_middle_name !== 'hidden'; @@ -131,6 +159,16 @@ const ProfilePage: React.FC = () => { const req = (field: string) => settings[`profile_field_${field}` as keyof typeof settings] === 'required'; return ( + <> + {showPasswordPrompt && ( + { setShowPasswordPrompt(false); setPendingPayload(null); }} + /> + )}
@@ -253,6 +291,7 @@ const ProfilePage: React.FC = () => {
+ ); }; diff --git a/pkg/account/mfa.go b/pkg/account/mfa.go index 62c58cb..f7c9767 100644 --- a/pkg/account/mfa.go +++ b/pkg/account/mfa.go @@ -51,6 +51,16 @@ func HandleSetupTotp(w http.ResponseWriter, r *http.Request) { return } + var req PasswordConfirmRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + utils.WriteErrorResponse(w, http.StatusBadRequest, "invalid_request", "Invalid request body") + return + } + + if !verifyCurrentPassword(w, usr, req.CurrentPassword) { + return + } + // Block re-enrollment if TOTP is already verified — must disable first. if usr.TotpVerified { utils.WriteErrorResponse(w, http.StatusConflict, "already_enrolled", "TOTP is already enrolled. Disable it first before re-enrolling.") diff --git a/pkg/account/mfa_test.go b/pkg/account/mfa_test.go index 2e1cd10..44284de 100644 --- a/pkg/account/mfa_test.go +++ b/pkg/account/mfa_test.go @@ -31,13 +31,17 @@ func TestHandleVerifyTotp(t *testing.T) { testutils.WithTestDB(t) _, usr, info := setupTestUserAndSession(t) - _ = mockAuthRequest(t, "", "POST", "/account/mfa/totp/setup", HandleSetupTotp, info) + hashedPw, _ := bcrypt.GenerateFromPassword([]byte("password"), bcrypt.DefaultCost) + _, _ = db.GetDB().Exec("UPDATE users SET password = ? WHERE id = ?", string(hashedPw), usr.ID) + + setupReq := PasswordConfirmRequest{CurrentPassword: "password"} + setupBody, _ := json.Marshal(setupReq) + _ = mockAuthRequest(t, string(setupBody), "POST", "/account/mfa/totp/setup", HandleSetupTotp, info) currUser, _ := user.UserByID(usr.ID) secret := currUser.TotpSecret code, _ := totp.GenerateCode(secret, time.Now()) - verifyReq := TotpVerifyRequest{Code: code} body, _ := json.Marshal(verifyReq) rr := mockAuthRequest(t, string(body), "POST", "/account/mfa/totp/verify", HandleVerifyTotp, info) @@ -197,7 +201,9 @@ func TestHandleMfaFlow(t *testing.T) { assert.False(t, statusResp.Data.TotpEnabled) // 2. Setup TOTP - rr = mockAuthRequest(t, "", "POST", "/account/api/mfa/totp/setup", HandleSetupTotp, info) + setupReq := PasswordConfirmRequest{CurrentPassword: "password123"} + setupBody, _ := json.Marshal(setupReq) + rr = mockAuthRequest(t, string(setupBody), "POST", "/account/api/mfa/totp/setup", HandleSetupTotp, info) assert.Equal(t, http.StatusOK, rr.Code) var setupResp model.ApiResponse[TotpSetupResponse] @@ -229,15 +235,63 @@ func TestHandleMfaFlow(t *testing.T) { assert.Equal(t, http.StatusOK, rr.Code) } -func TestHandleSetupTotp_AlreadyEnrolled(t *testing.T) { +func TestHandleSetupTotp_RequiresPassword(t *testing.T) { + testutils.WithTestDB(t) + _, usr, info := setupTestUserAndSession(t) + + hashedPassword, _ := bcrypt.GenerateFromPassword([]byte("password"), bcrypt.DefaultCost) + _, _ = db.GetDB().Exec("UPDATE users SET password = ? WHERE id = ?", string(hashedPassword), usr.ID) + + // Without password — should fail + req := PasswordConfirmRequest{} + body, _ := json.Marshal(req) + rr := mockAuthRequest(t, string(body), "POST", "/account/api/mfa/totp/setup", HandleSetupTotp, info) + assert.Equal(t, http.StatusForbidden, rr.Code) + + // Wrong password — should fail + req = PasswordConfirmRequest{CurrentPassword: "wrong"} + body, _ = json.Marshal(req) + rr = mockAuthRequest(t, string(body), "POST", "/account/api/mfa/totp/setup", HandleSetupTotp, info) + assert.Equal(t, http.StatusForbidden, rr.Code) + + // Correct password — should succeed + req = PasswordConfirmRequest{CurrentPassword: "password"} + body, _ = json.Marshal(req) + rr = mockAuthRequest(t, string(body), "POST", "/account/api/mfa/totp/setup", HandleSetupTotp, info) + assert.Equal(t, http.StatusOK, rr.Code) +} + +func TestHandleSetupTotp_PasskeyUserSkipsPassword(t *testing.T) { testutils.WithTestDB(t) _, usr, info := setupTestUserAndSession(t) - // Mark TOTP as verified - _, _ = db.GetDB().Exec("UPDATE users SET totp_secret = 'JBSWY3DPEHPK3PXP', totp_verified = TRUE WHERE id = ?", usr.ID) + _, _ = db.GetDB().Exec("UPDATE users SET password = '' WHERE id = ?", usr.ID) + + req := PasswordConfirmRequest{} + body, _ := json.Marshal(req) + rr := mockAuthRequest(t, string(body), "POST", "/account/api/mfa/totp/setup", HandleSetupTotp, info) + assert.Equal(t, http.StatusOK, rr.Code) +} + +func TestHandleSetupTotp_InvalidJSON(t *testing.T) { + testutils.WithTestDB(t) + _, _, info := setupTestUserAndSession(t) + + rr := mockAuthRequest(t, "{invalid", "POST", "/account/api/mfa/totp/setup", HandleSetupTotp, info) + assert.Equal(t, http.StatusBadRequest, rr.Code) +} + +func TestHandleSetupTotp_AlreadyEnrolled(t *testing.T) { + testutils.WithTestDB(t) + _, usr, info := setupTestUserAndSession(t) + // Set password and mark TOTP as verified + hashedPw, _ := bcrypt.GenerateFromPassword([]byte("password"), bcrypt.DefaultCost) + _, _ = db.GetDB().Exec("UPDATE users SET password = ?, totp_secret = 'JBSWY3DPEHPK3PXP', totp_verified = TRUE WHERE id = ?", string(hashedPw), usr.ID) - rr := mockAuthRequest(t, "", "POST", "/account/api/mfa/totp/setup", HandleSetupTotp, info) + setupReq := PasswordConfirmRequest{CurrentPassword: "password"} + body, _ := json.Marshal(setupReq) + rr := mockAuthRequest(t, string(body), "POST", "/account/api/mfa/totp/setup", HandleSetupTotp, info) assert.Equal(t, http.StatusConflict, rr.Code) assert.Contains(t, rr.Body.String(), "already_enrolled") } diff --git a/pkg/account/model.go b/pkg/account/model.go index 0041f11..8069c23 100644 --- a/pkg/account/model.go +++ b/pkg/account/model.go @@ -48,6 +48,33 @@ type DisableMfaRequest struct { Code string `json:"code"` } +type PasswordConfirmRequest struct { + CurrentPassword string `json:"current_password"` +} + +type ProfileUpdateRequest struct { + CurrentPassword string `json:"current_password"` + Username string `json:"username,omitempty"` + Email string `json:"email,omitempty"` + GivenName string `json:"given_name,omitempty"` + FamilyName string `json:"family_name,omitempty"` + MiddleName string `json:"middle_name,omitempty"` + Nickname string `json:"nickname,omitempty"` + PhoneNumber string `json:"phone_number,omitempty"` + Picture string `json:"picture,omitempty"` + Website string `json:"website,omitempty"` + Gender string `json:"gender,omitempty"` + Birthdate string `json:"birthdate,omitempty"` + ProfileURL string `json:"profile,omitempty"` + Locale string `json:"locale,omitempty"` + Zoneinfo string `json:"zoneinfo,omitempty"` + AddressStreet string `json:"address_street,omitempty"` + AddressLocality string `json:"address_locality,omitempty"` + AddressRegion string `json:"address_region,omitempty"` + AddressPostalCode string `json:"address_postal_code,omitempty"` + AddressCountry string `json:"address_country,omitempty"` +} + type PasskeyRenameRequest struct { Name string `json:"name"` } diff --git a/pkg/account/passkey.go b/pkg/account/passkey.go index 4121438..c3335e8 100644 --- a/pkg/account/passkey.go +++ b/pkg/account/passkey.go @@ -71,6 +71,16 @@ func HandleDeletePasskey(w http.ResponseWriter, r *http.Request) { return } + var req PasswordConfirmRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + utils.WriteErrorResponse(w, http.StatusBadRequest, "invalid_request", "Invalid request body") + return + } + + if !verifyCurrentPassword(w, usr, req.CurrentPassword) { + return + } + passkeyID := r.PathValue("id") if passkeyID == "" { utils.WriteErrorResponse(w, http.StatusBadRequest, "invalid_request", "Missing passkey ID") @@ -156,6 +166,16 @@ func HandleAddPasskeyBegin(w http.ResponseWriter, r *http.Request) { return } + var req PasswordConfirmRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + utils.WriteErrorResponse(w, http.StatusBadRequest, "invalid_request", "Invalid request body") + return + } + + if !verifyCurrentPassword(w, usr, req.CurrentPassword) { + return + } + existingCreds, _ := passkey.PasskeyCredentialsByUserID(usr.ID) wauthn, err := passkey.NewWebAuthn() if err != nil { diff --git a/pkg/account/passkey_test.go b/pkg/account/passkey_test.go index 7b09fc4..92b70f8 100644 --- a/pkg/account/passkey_test.go +++ b/pkg/account/passkey_test.go @@ -14,9 +14,11 @@ import ( "github.com/eugenioenko/autentico/pkg/middleware" "github.com/eugenioenko/autentico/pkg/model" "github.com/eugenioenko/autentico/pkg/passkey" + "github.com/eugenioenko/autentico/pkg/user" testutils "github.com/eugenioenko/autentico/tests/utils" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "golang.org/x/crypto/bcrypt" ) func TestHandleListPasskeys(t *testing.T) { @@ -38,6 +40,11 @@ func TestHandleDeletePasskey(t *testing.T) { testutils.WithTestDB(t) _, usr, info := setupTestUserAndSession(t) + // Clear password (passkey-only user) and refresh info + _, _ = db.GetDB().Exec("UPDATE users SET password = '' WHERE id = ?", usr.ID) + refreshed, _ := user.UserByID(usr.ID) + info.User = refreshed + _ = passkey.CreatePasskeyCredential(passkey.PasskeyCredential{ ID: "pk1", UserID: usr.ID, @@ -48,14 +55,18 @@ func TestHandleDeletePasskey(t *testing.T) { mux := http.NewServeMux() mux.HandleFunc("DELETE /account/api/passkeys/{id}", HandleDeletePasskey) - req := httptest.NewRequest("DELETE", "/account/api/passkeys/pk1", nil) + // Passkey user (no password) — should succeed + deleteReq := PasswordConfirmRequest{} + body, _ := json.Marshal(deleteReq) + req := httptest.NewRequest("DELETE", "/account/api/passkeys/pk1", bytes.NewBuffer(body)) req = middleware.WithAuthInfo(req, info) rr := httptest.NewRecorder() mux.ServeHTTP(rr, req) assert.Equal(t, http.StatusOK, rr.Code) // Not owned - req = httptest.NewRequest("DELETE", "/account/api/passkeys/otherpk", nil) + body, _ = json.Marshal(deleteReq) + req = httptest.NewRequest("DELETE", "/account/api/passkeys/otherpk", bytes.NewBuffer(body)) req = middleware.WithAuthInfo(req, info) rr = httptest.NewRecorder() mux.ServeHTTP(rr, req) @@ -94,14 +105,20 @@ func TestHandleRenamePasskey(t *testing.T) { func TestHandleAddPasskeyBegin(t *testing.T) { testutils.WithTestDB(t) - _, _, info := setupTestUserAndSession(t) + _, usr, info := setupTestUserAndSession(t) + + // Clear password (passkey-only user) + _, _ = db.GetDB().Exec("UPDATE users SET password = '' WHERE id = ?", usr.ID) + testutils.WithConfigOverride(t, func() { config.Bootstrap.AppDomain = "localhost" config.Bootstrap.AppURL = "http://localhost" config.Values.PasskeyRPName = "Test" }) - rr := mockAuthRequest(t, "", "POST", "/account/api/passkeys/add/begin", HandleAddPasskeyBegin, info) + req := PasswordConfirmRequest{} + body, _ := json.Marshal(req) + rr := mockAuthRequest(t, string(body), "POST", "/account/api/passkeys/add/begin", HandleAddPasskeyBegin, info) assert.Equal(t, http.StatusOK, rr.Code) } @@ -182,9 +199,16 @@ func TestHandleAddPasskeyFinish_MissingChallengeID(t *testing.T) { func TestHandleAddPasskeyBegin_Success(t *testing.T) { testutils.WithTestDB(t) - _, _, info := setupTestUserAndSession(t) + _, usr, info := setupTestUserAndSession(t) - req := httptest.NewRequest("POST", "/account/api/passkeys/add/begin", nil) + // Clear password (passkey-only user) and refresh info + _, _ = db.GetDB().Exec("UPDATE users SET password = '' WHERE id = ?", usr.ID) + refreshed, _ := user.UserByID(usr.ID) + info.User = refreshed + + confirmReq := PasswordConfirmRequest{} + body, _ := json.Marshal(confirmReq) + req := httptest.NewRequest("POST", "/account/api/passkeys/add/begin", bytes.NewBuffer(body)) req = middleware.WithAuthInfo(req, info) rr := httptest.NewRecorder() HandleAddPasskeyBegin(rr, req) @@ -298,6 +322,84 @@ func TestHandleRenamePasskey_Success_Extra(t *testing.T) { assert.Equal(t, "New Name", name) } +func TestHandleAddPasskeyBegin_RequiresPassword(t *testing.T) { + testutils.WithTestDB(t) + _, usr, info := setupTestUserAndSession(t) + + hashedPassword, _ := bcrypt.GenerateFromPassword([]byte("password"), bcrypt.DefaultCost) + _, _ = db.GetDB().Exec("UPDATE users SET password = ? WHERE id = ?", string(hashedPassword), usr.ID) + + // Wrong password — should fail + req := PasswordConfirmRequest{CurrentPassword: "wrong"} + body, _ := json.Marshal(req) + rr := mockAuthRequest(t, string(body), "POST", "/account/api/passkeys/add/begin", HandleAddPasskeyBegin, info) + assert.Equal(t, http.StatusForbidden, rr.Code) + + // Correct password — should succeed + req = PasswordConfirmRequest{CurrentPassword: "password"} + body, _ = json.Marshal(req) + rr = mockAuthRequest(t, string(body), "POST", "/account/api/passkeys/add/begin", HandleAddPasskeyBegin, info) + assert.Equal(t, http.StatusOK, rr.Code) +} + +func TestHandleDeletePasskey_RequiresPassword(t *testing.T) { + testutils.WithTestDB(t) + _, usr, info := setupTestUserAndSession(t) + + hashedPassword, _ := bcrypt.GenerateFromPassword([]byte("password"), bcrypt.DefaultCost) + _, _ = db.GetDB().Exec("UPDATE users SET password = ? WHERE id = ?", string(hashedPassword), usr.ID) + + _ = passkey.CreatePasskeyCredential(passkey.PasskeyCredential{ + ID: "pk-pwd", + UserID: usr.ID, + Name: "Test", + Credential: "{}", + }) + + mux := http.NewServeMux() + mux.HandleFunc("DELETE /account/api/passkeys/{id}", HandleDeletePasskey) + + // Wrong password — should fail + deleteReq := PasswordConfirmRequest{CurrentPassword: "wrong"} + body, _ := json.Marshal(deleteReq) + req := httptest.NewRequest("DELETE", "/account/api/passkeys/pk-pwd", bytes.NewBuffer(body)) + req = middleware.WithAuthInfo(req, info) + rr := httptest.NewRecorder() + mux.ServeHTTP(rr, req) + assert.Equal(t, http.StatusForbidden, rr.Code) + + // Correct password — should succeed + deleteReq = PasswordConfirmRequest{CurrentPassword: "password"} + body, _ = json.Marshal(deleteReq) + req = httptest.NewRequest("DELETE", "/account/api/passkeys/pk-pwd", bytes.NewBuffer(body)) + req = middleware.WithAuthInfo(req, info) + rr = httptest.NewRecorder() + mux.ServeHTTP(rr, req) + assert.Equal(t, http.StatusOK, rr.Code) +} + +func TestHandleAddPasskeyBegin_InvalidJSON(t *testing.T) { + testutils.WithTestDB(t) + _, _, info := setupTestUserAndSession(t) + + rr := mockAuthRequest(t, "{invalid", "POST", "/account/api/passkeys/add/begin", HandleAddPasskeyBegin, info) + assert.Equal(t, http.StatusBadRequest, rr.Code) +} + +func TestHandleDeletePasskey_InvalidJSON(t *testing.T) { + testutils.WithTestDB(t) + _, _, info := setupTestUserAndSession(t) + + mux := http.NewServeMux() + mux.HandleFunc("DELETE /account/api/passkeys/{id}", HandleDeletePasskey) + + req := httptest.NewRequest("DELETE", "/account/api/passkeys/pk1", nil) + req = middleware.WithAuthInfo(req, info) + rr := httptest.NewRecorder() + mux.ServeHTTP(rr, req) + assert.Equal(t, http.StatusBadRequest, rr.Code) +} + func TestHandleDeletePasskey_Extra(t *testing.T) { testutils.WithTestDB(t) _, _, info := setupTestUserAndSession(t) @@ -305,7 +407,9 @@ func TestHandleDeletePasskey_Extra(t *testing.T) { mux := http.NewServeMux() mux.HandleFunc("DELETE /account/api/passkeys/{id}", HandleDeletePasskey) - req := httptest.NewRequest("DELETE", "/account/api/passkeys/none", nil) + deleteReq := PasswordConfirmRequest{} + body, _ := json.Marshal(deleteReq) + req := httptest.NewRequest("DELETE", "/account/api/passkeys/none", bytes.NewBuffer(body)) req = middleware.WithAuthInfo(req, info) rr := httptest.NewRecorder() mux.ServeHTTP(rr, req) diff --git a/pkg/account/profile.go b/pkg/account/profile.go index d65a1c2..4d897fa 100644 --- a/pkg/account/profile.go +++ b/pkg/account/profile.go @@ -13,6 +13,21 @@ import ( "github.com/eugenioenko/autentico/pkg/utils" ) +func verifyCurrentPassword(w http.ResponseWriter, usr *user.User, currentPassword string) bool { + if usr.Password == "" { + return true + } + if err := user.VerifyPassword(usr.ID, currentPassword); err != nil { + if errors.Is(err, user.ErrAccountLocked) { + utils.WriteErrorResponse(w, http.StatusTooManyRequests, "account_locked", "Account is temporarily locked") + return false + } + utils.WriteErrorResponse(w, http.StatusForbidden, "invalid_password", "Current password is required to perform this action") + return false + } + return true +} + // HandleGetProfile godoc // @Summary Get current user profile // @Description Returns the authenticated user's profile information. @@ -52,7 +67,7 @@ func HandleUpdateProfile(w http.ResponseWriter, r *http.Request) { return } - var req user.UserUpdateRequest + var req ProfileUpdateRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { utils.WriteErrorResponse(w, http.StatusBadRequest, "invalid_request", "Invalid request body") return @@ -86,15 +101,23 @@ func HandleUpdateProfile(w http.ResponseWriter, r *http.Request) { return } + emailChanging := req.Email != "" && req.Email != usr.Email + usernameChanging := req.Username != "" && req.Username != usr.Username + // Check email uniqueness if changing email - if req.Email != "" && req.Email != usr.Email { + if emailChanging { if user.UserExistsByEmail(req.Email) { utils.WriteErrorResponse(w, http.StatusConflict, "email_taken", "Email address already in use") return } } - // Allow updating profile fields only — exclude password, role, totp settings + if emailChanging || usernameChanging { + if !verifyCurrentPassword(w, usr, req.CurrentPassword) { + return + } + } + updateReq := user.UserUpdateRequest{ Email: req.Email, Username: req.Username, @@ -117,6 +140,11 @@ func HandleUpdateProfile(w http.ResponseWriter, r *http.Request) { AddressCountry: req.AddressCountry, } + if emailChanging { + f := false + updateReq.IsEmailVerified = &f + } + if err := user.ValidateUserUpdateRequest(updateReq); err != nil { utils.WriteErrorResponse(w, http.StatusBadRequest, "validation_error", err.Error()) return diff --git a/pkg/account/profile_test.go b/pkg/account/profile_test.go index 0ee3ac0..de6f2e7 100644 --- a/pkg/account/profile_test.go +++ b/pkg/account/profile_test.go @@ -42,7 +42,7 @@ func TestHandleUpdateProfile_AllFields(t *testing.T) { testutils.WithTestDB(t) _, _, info := setupTestUserAndSession(t) - updateReq := user.UserUpdateRequest{ + updateReq := ProfileUpdateRequest{ GivenName: "John", FamilyName: "Doe", PhoneNumber: "+123456789", @@ -62,12 +62,15 @@ func TestHandleUpdateProfile_AllFields(t *testing.T) { func TestHandleUpdateProfile_Errors(t *testing.T) { testutils.WithTestDB(t) - _, _, info := setupTestUserAndSession(t) + _, usr, info := setupTestUserAndSession(t) + + // Clear password so password check is skipped in these validation tests + _, _ = db.GetDB().Exec("UPDATE users SET password = '' WHERE id = ?", usr.ID) // Username change not allowed testutils.WithConfigOverride(t, func() { config.Values.AllowUsernameChange = false - req := user.UserUpdateRequest{Username: "newusername"} + req := ProfileUpdateRequest{Username: "newusername"} b, _ := json.Marshal(req) rr := mockAuthRequest(t, string(b), "POST", "/account/profile", HandleUpdateProfile, info) assert.Equal(t, http.StatusForbidden, rr.Code) @@ -78,7 +81,7 @@ func TestHandleUpdateProfile_Errors(t *testing.T) { config.Values.AllowEmailChange = true otherUserID := uuid.New().String() _, _ = db.GetDB().Exec("INSERT INTO users (id, username, email) VALUES (?, ?, ?)", otherUserID, "other", "other@test.com") - req := user.UserUpdateRequest{Email: "other@test.com"} + req := ProfileUpdateRequest{Email: "other@test.com", CurrentPassword: "password"} b, _ := json.Marshal(req) rr := mockAuthRequest(t, string(b), "POST", "/account/profile", HandleUpdateProfile, info) assert.Equal(t, http.StatusConflict, rr.Code) @@ -88,10 +91,10 @@ func TestHandleUpdateProfile_Errors(t *testing.T) { rr := mockAuthRequest(t, "{invalid", "POST", "/account/profile", HandleUpdateProfile, info) assert.Equal(t, http.StatusBadRequest, rr.Code) - // Validation error + // Validation error — passkey user (no password) so password check is skipped testutils.WithConfigOverride(t, func() { config.Values.AllowUsernameChange = true - req := user.UserUpdateRequest{Username: "a"} // too short + req := ProfileUpdateRequest{Username: "a"} // too short b, _ := json.Marshal(req) rr := mockAuthRequest(t, string(b), "POST", "/account/profile", HandleUpdateProfile, info) assert.Equal(t, http.StatusBadRequest, rr.Code) @@ -160,6 +163,129 @@ func TestHandleGetProfile_Success(t *testing.T) { assert.Equal(t, u.Username, resp.Data.Username) } +func TestHandleUpdateProfile_EmailChangeRequiresPassword(t *testing.T) { + testutils.WithTestDB(t) + _, usr, info := setupTestUserAndSession(t) + + hashedPassword, _ := bcrypt.GenerateFromPassword([]byte("password"), bcrypt.DefaultCost) + _, _ = db.GetDB().Exec("UPDATE users SET password = ? WHERE id = ?", string(hashedPassword), usr.ID) + + testutils.WithConfigOverride(t, func() { + config.Values.AllowEmailChange = true + + // Without password — should fail + req := ProfileUpdateRequest{Email: "new@test.com"} + b, _ := json.Marshal(req) + rr := mockAuthRequest(t, string(b), "PUT", "/account/api/profile", HandleUpdateProfile, info) + assert.Equal(t, http.StatusForbidden, rr.Code) + + // Wrong password — should fail + req = ProfileUpdateRequest{Email: "new@test.com", CurrentPassword: "wrong"} + b, _ = json.Marshal(req) + rr = mockAuthRequest(t, string(b), "PUT", "/account/api/profile", HandleUpdateProfile, info) + assert.Equal(t, http.StatusForbidden, rr.Code) + + // Correct password — should succeed + req = ProfileUpdateRequest{Email: "new@test.com", CurrentPassword: "password"} + b, _ = json.Marshal(req) + rr = mockAuthRequest(t, string(b), "PUT", "/account/api/profile", HandleUpdateProfile, info) + assert.Equal(t, http.StatusOK, rr.Code) + + // Verify email_verified was reset + updated, _ := user.UserByID(usr.ID) + assert.False(t, updated.IsEmailVerified) + }) +} + +func TestHandleUpdateProfile_UsernameChangeRequiresPassword(t *testing.T) { + testutils.WithTestDB(t) + _, usr, info := setupTestUserAndSession(t) + + hashedPassword, _ := bcrypt.GenerateFromPassword([]byte("password"), bcrypt.DefaultCost) + _, _ = db.GetDB().Exec("UPDATE users SET password = ? WHERE id = ?", string(hashedPassword), usr.ID) + + testutils.WithConfigOverride(t, func() { + config.Values.AllowUsernameChange = true + + // Without password — should fail + req := ProfileUpdateRequest{Username: "newname"} + b, _ := json.Marshal(req) + rr := mockAuthRequest(t, string(b), "PUT", "/account/api/profile", HandleUpdateProfile, info) + assert.Equal(t, http.StatusForbidden, rr.Code) + + // Correct password — should succeed + req = ProfileUpdateRequest{Username: "newname", CurrentPassword: "password"} + b, _ = json.Marshal(req) + rr = mockAuthRequest(t, string(b), "PUT", "/account/api/profile", HandleUpdateProfile, info) + assert.Equal(t, http.StatusOK, rr.Code) + }) +} + +func TestHandleUpdateProfile_NonSensitiveFieldsNoPassword(t *testing.T) { + testutils.WithTestDB(t) + _, _, info := setupTestUserAndSession(t) + + req := ProfileUpdateRequest{GivenName: "NewName", FamilyName: "NewFamily"} + b, _ := json.Marshal(req) + rr := mockAuthRequest(t, string(b), "PUT", "/account/api/profile", HandleUpdateProfile, info) + assert.Equal(t, http.StatusOK, rr.Code) +} + +func TestHandleUpdateProfile_PasskeyUserSkipsPasswordCheck(t *testing.T) { + testutils.WithTestDB(t) + _, usr, info := setupTestUserAndSession(t) + + // Passkey user has empty password + _, _ = db.GetDB().Exec("UPDATE users SET password = '' WHERE id = ?", usr.ID) + + testutils.WithConfigOverride(t, func() { + config.Values.AllowEmailChange = true + + req := ProfileUpdateRequest{Email: "new@test.com"} + b, _ := json.Marshal(req) + rr := mockAuthRequest(t, string(b), "PUT", "/account/api/profile", HandleUpdateProfile, info) + assert.Equal(t, http.StatusOK, rr.Code) + }) +} + +func TestHandleUpdateProfile_EmailChangeResetsVerified(t *testing.T) { + testutils.WithTestDB(t) + _, usr, info := setupTestUserAndSession(t) + + _, _ = db.GetDB().Exec("UPDATE users SET password = '', is_email_verified = TRUE WHERE id = ?", usr.ID) + + testutils.WithConfigOverride(t, func() { + config.Values.AllowEmailChange = true + + req := ProfileUpdateRequest{Email: "changed@test.com"} + b, _ := json.Marshal(req) + rr := mockAuthRequest(t, string(b), "PUT", "/account/api/profile", HandleUpdateProfile, info) + assert.Equal(t, http.StatusOK, rr.Code) + + updated, _ := user.UserByID(usr.ID) + assert.Equal(t, "changed@test.com", updated.Email) + assert.False(t, updated.IsEmailVerified) + }) +} + +func TestHandleUpdateProfile_SameEmailNoPasswordRequired(t *testing.T) { + testutils.WithTestDB(t) + _, usr, info := setupTestUserAndSession(t) + + hashedPassword, _ := bcrypt.GenerateFromPassword([]byte("password"), bcrypt.DefaultCost) + _, _ = db.GetDB().Exec("UPDATE users SET password = ? WHERE id = ?", string(hashedPassword), usr.ID) + + testutils.WithConfigOverride(t, func() { + config.Values.AllowEmailChange = true + + // Submitting same email should not require password + req := ProfileUpdateRequest{Email: usr.Email, GivenName: "Updated"} + b, _ := json.Marshal(req) + rr := mockAuthRequest(t, string(b), "PUT", "/account/api/profile", HandleUpdateProfile, info) + assert.Equal(t, http.StatusOK, rr.Code) + }) +} + func TestHandleUpdateProfile_DbError(t *testing.T) { testutils.WithTestDB(t) testutils.WithConfigOverride(t, func() { @@ -167,7 +293,7 @@ func TestHandleUpdateProfile_DbError(t *testing.T) { }) _, _, info := setupTestUserAndSession(t) - req := user.UserUpdateRequest{GivenName: "New Name"} + req := ProfileUpdateRequest{GivenName: "New Name"} body, _ := json.Marshal(req) // Close DB to trigger error in UpdateUser diff --git a/tests/functional/tests/mfa-enforcement.test.ts b/tests/functional/tests/mfa-enforcement.test.ts index da03621..05887f0 100644 --- a/tests/functional/tests/mfa-enforcement.test.ts +++ b/tests/functional/tests/mfa-enforcement.test.ts @@ -47,9 +47,9 @@ async function getUserToken(username: string, password: string, totpCode?: strin /** * Helper: setup and verify TOTP for a user, returning the secret. */ -async function setupAndVerifyTotp(userToken: string): Promise { +async function setupAndVerifyTotp(userToken: string, password = 'Password123!'): Promise { // Setup TOTP - const setupResp = await postJSON(`${ACCOUNT_API}/mfa/totp/setup`, {}, userToken); + const setupResp = await postJSON(`${ACCOUNT_API}/mfa/totp/setup`, { current_password: password }, userToken); expect(setupResp.status).toBe(200); const setupBody = await setupResp.json(); const secret = setupBody.data.secret; @@ -166,7 +166,7 @@ describe('TOTP re-enrollment blocked', () => { // Try to setup again — should be blocked // Need a fresh token since the user now has TOTP - const secret2Resp = await postJSON(`${ACCOUNT_API}/mfa/totp/setup`, {}, userToken); + const secret2Resp = await postJSON(`${ACCOUNT_API}/mfa/totp/setup`, { current_password: 'Password123!' }, userToken); expect(secret2Resp.status).toBe(409); const body = await secret2Resp.json(); expect(body.error).toBe('already_enrolled'); diff --git a/tests/security/security_account_test.go b/tests/security/security_account_test.go index 74fec76..29ea19c 100644 --- a/tests/security/security_account_test.go +++ b/tests/security/security_account_test.go @@ -250,7 +250,7 @@ func TestAccount_PasskeyDeleteIDOR(t *testing.T) { // User A tries to delete user B's passkey status, _ := doAccountRequest(t, ts, "DELETE", tokenA.AccessToken, - "/account/api/passkeys/"+fakeCredID, "") + "/account/api/passkeys/"+fakeCredID, `{"current_password":"password123"}`) assert.True(t, status == http.StatusForbidden || status == http.StatusNotFound, "IDOR: user A must not delete user B's passkey, got %d", status) }