diff --git a/include/records.hrl b/include/records.hrl index 14bef78..be6e6b3 100755 --- a/include/records.hrl +++ b/include/records.hrl @@ -35,6 +35,12 @@ expires_at :: calendar:datetime() }). +-record(password_reset, { + token :: binary(), + user_id :: binary(), + expires_at :: calendar:datetime() +}). + %% ------------------- АДМИНИСТРАТОРЫ ------------------------------------ -record(admin, { id :: binary(), diff --git a/src/core/core_auth_session.erl b/src/core/core_auth_session.erl old mode 100644 new mode 100755 index 435a2b4..2b9da03 --- a/src/core/core_auth_session.erl +++ b/src/core/core_auth_session.erl @@ -5,7 +5,7 @@ %%%------------------------------------------------------------------- -module(core_auth_session). -include("records.hrl"). --export([create/3, get/1, rotate/2, revoke/1, revoke_family/1]). +-export([create/3, get/1, rotate/2, revoke/1, revoke_family/1, revoke_all_for_subject/2]). -define(REFRESH_TTL_SECONDS, 30 * 24 * 3600). @@ -99,6 +99,26 @@ revoke_family(FamilyId) -> end, Sessions), ok. +%%%------------------------------------------------------------------- +%%% @doc Отозвать все сессии субъекта (например после сброса пароля). +%%% @end +%%%------------------------------------------------------------------- +-spec revoke_all_for_subject(SubjectId :: binary(), SubjectType :: user | admin) -> ok. +revoke_all_for_subject(SubjectId, SubjectType) -> + Sessions = mnesia:dirty_match_object(#auth_session{subject_id = SubjectId, _ = '_'}), + Now = calendar:universal_time(), + lists:foreach(fun(Session) -> + case Session#auth_session.subject_type =:= SubjectType andalso + Session#auth_session.revoked =:= false of + true -> + mnesia:dirty_write(Session#auth_session{revoked = true, updated_at = Now}), + dec_counter(Session#auth_session.subject_type); + false -> + ok + end + end, Sessions), + ok. + %%%=================================================================== %%% Internal %%%=================================================================== diff --git a/src/core/core_password_reset.erl b/src/core/core_password_reset.erl new file mode 100755 index 0000000..4e845b1 --- /dev/null +++ b/src/core/core_password_reset.erl @@ -0,0 +1,85 @@ +-module(core_password_reset). +-include("records.hrl"). +-export([create_token/1, verify_token/1, get_or_create_token/1, delete_token/1]). + +-define(TOKEN_LIFETIME_HOURS, 1). + +%%%------------------------------------------------------------------- +%%% @doc Создаёт токен сброса пароля для пользователя. +%%% @end +%%%------------------------------------------------------------------- +-spec create_token(UserId :: binary()) -> {ok, Token :: binary(), ExpiresAt :: calendar:datetime()}. +create_token(UserId) -> + Token = infra_utils:generate_id(32), + Expires = calendar:gregorian_seconds_to_datetime( + calendar:datetime_to_gregorian_seconds(calendar:universal_time()) + ?TOKEN_LIFETIME_HOURS * 3600), + mnesia:dirty_write(#password_reset{token = Token, user_id = UserId, expires_at = Expires}), + {ok, Token, Expires}. + +%%%------------------------------------------------------------------- +%%% @doc Проверяет токен. Возвращает `{ok, UserId}` или ошибку. +%%% @end +%%%------------------------------------------------------------------- +-spec verify_token(Token :: binary()) -> + {ok, UserId :: binary()} | {error, expired | not_found}. +verify_token(Token) -> + case mnesia:dirty_read(password_reset, Token) of + [#password_reset{user_id = UserId, expires_at = Expires}] -> + case Expires > calendar:universal_time() of + true -> {ok, UserId}; + false -> {error, expired} + end; + [] -> {error, not_found} + end. + +%%%------------------------------------------------------------------- +%%% @doc Возвращает неистёкший токен пользователя или создаёт новый. +%%% @end +%%%------------------------------------------------------------------- +-spec get_or_create_token(UserId :: binary()) -> + {ok, Token :: binary(), ExpiresAt :: calendar:datetime()} | {error, not_found}. +get_or_create_token(UserId) -> + case find_valid_token(UserId) of + {ok, Token, ExpiresAt} -> + {ok, Token, ExpiresAt}; + not_found -> + case user_exists(UserId) of + true -> create_token(UserId); + false -> {error, not_found} + end + end. + +%%%------------------------------------------------------------------- +%%% @doc Удаляет токен. +%%% @end +%%%------------------------------------------------------------------- +-spec delete_token(Token :: binary()) -> ok. +delete_token(Token) -> + mnesia:dirty_delete(password_reset, Token), + ok. + +%%-------------------------------------------------------------------- +find_valid_token(UserId) -> + Now = calendar:universal_time(), + case mnesia:dirty_match_object(#password_reset{user_id = UserId, _ = '_'}) of + [] -> + not_found; + Rows -> + Valid = [R || R <- Rows, R#password_reset.expires_at > Now], + case Valid of + [#password_reset{token = Token, expires_at = ExpiresAt} | _] -> + {ok, Token, ExpiresAt}; + [] -> + not_found + end + end. + +user_exists(UserId) -> + case core_user:get_by_id(UserId) of + {ok, _} -> true; + {error, not_found} -> + case mnesia:transaction(fun() -> mnesia:read(user, UserId) end) of + {atomic, [_ | _]} -> true; + _ -> false + end + end. diff --git a/src/eventhub_app.erl b/src/eventhub_app.erl index a55bfcf..a3a5166 100755 --- a/src/eventhub_app.erl +++ b/src/eventhub_app.erl @@ -85,6 +85,8 @@ start_http() -> {"/health", handler_health, []}, {"/v1/register", handler_register, []}, {"/v1/verify", handler_verify, []}, + {"/v1/forgot-password", handler_forgot_password, []}, + {"/v1/reset-password", handler_reset_password, []}, {"/v1/login", handler_login, []}, {"/v1/refresh", handler_refresh, []}, {"/v1/user/me", handler_user_me, []}, @@ -148,6 +150,7 @@ start_admin_http() -> {"/v1/admin/users/stats", admin_handler_user_stats, []}, {"/v1/admin/users/:id", admin_handler_user_by_id, []}, {"/v1/admin/users/:id/verification-token", admin_handler_user_verification_token, []}, + {"/v1/admin/users/:id/password-reset-token", admin_handler_user_password_reset_token, []}, % ================== КАЛЕНДАРИ ================== {"/v1/admin/calendars", admin_handler_calendars, []}, {"/v1/admin/calendars/stats", admin_handler_calendar_stats, []}, diff --git a/src/handlers/admin/admin_handler_user_password_reset_token.erl b/src/handlers/admin/admin_handler_user_password_reset_token.erl new file mode 100755 index 0000000..09e4bab --- /dev/null +++ b/src/handlers/admin/admin_handler_user_password_reset_token.erl @@ -0,0 +1,35 @@ +-module(admin_handler_user_password_reset_token). +-behaviour(cowboy_handler). +-export([init/2]). +-export([trails/0]). + +init(Req, _Opts) -> + case cowboy_req:method(Req) of + <<"GET">> -> get_token(Req); + _ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>) + end. + +get_token(Req) -> + case handler_utils:auth_admin(Req) of + {ok, _AdminId, Req1} -> + UserId = cowboy_req:binding(id, Req1), + case core_password_reset:get_or_create_token(UserId) of + {ok, Token, ExpiresAt} -> + handler_utils:send_json(Req1, 200, #{ + <<"token">> => Token, + <<"expires_at">> => handler_utils:datetime_to_iso8601(ExpiresAt) + }); + {error, not_found} -> + handler_utils:send_error(Req1, 404, <<"User not found">>) + end; + {error, Code, Msg, Req1} -> + handler_utils:send_error(Req1, Code, Msg) + end. + +trails() -> + [#{path => <<"/v1/admin/users/:id/password-reset-token">>, + method => <<"GET">>, + description => <<"Get or create password reset token for user (admin)">>, + tags => [<<"Users">>], + parameters => [#{name => <<"id">>, in => <<"path">>, required => true, schema => #{type => string}}], + responses => #{200 => #{description => <<"Token">>}}}]. diff --git a/src/handlers/handler_forgot_password.erl b/src/handlers/handler_forgot_password.erl new file mode 100755 index 0000000..d7b33de --- /dev/null +++ b/src/handlers/handler_forgot_password.erl @@ -0,0 +1,62 @@ +%%%------------------------------------------------------------------- +%%% @doc POST /v1/forgot-password — запрос сброса пароля по email. +%%% Ответ всегда 200 (без enumeration). +%%% @end +%%%------------------------------------------------------------------- +-module(handler_forgot_password). +-behaviour(cowboy_handler). +-export([init/2]). +-export([trails/0]). + +init(Req, _Opts) -> + case cowboy_req:method(Req) of + <<"POST">> -> forgot(Req); + _ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>) + end. + +forgot(Req) -> + case cowboy_req:has_body(Req) of + false -> + handler_utils:send_error(Req, 400, <<"Missing request body">>); + true -> + {ok, Body, Req1} = cowboy_req:read_body(Req), + try jsx:decode(Body, [return_maps]) of + #{<<"email">> := Email} when is_binary(Email), Email =/= <<>> -> + logic_password_reset:request_reset(Email), + handler_utils:send_json(Req1, 200, #{ + <<"message">> => <<"If the account exists, a reset email was sent">> + }); + _ -> + handler_utils:send_error(Req1, 400, <<"Missing email">>) + catch + _:_ -> handler_utils:send_error(Req1, 400, <<"Invalid JSON">>) + end + end. + +trails() -> + [ + #{ + path => <<"/v1/forgot-password">>, + method => <<"POST">>, + description => <<"Request password reset email (always 200; no email enumeration)">>, + tags => [<<"Auth">>], + requestBody => #{ + required => true, + content => #{ + <<"application/json">> => #{ + schema => #{ + type => object, + required => [<<"email">>], + properties => #{ + email => #{type => string, format => <<"email">>} + } + } + } + } + }, + responses => #{ + 200 => #{description => <<"Accepted (sent or silently ignored)">>}, + 400 => #{description => <<"Missing email or invalid JSON">>} + } + } + ]. diff --git a/src/handlers/handler_reset_password.erl b/src/handlers/handler_reset_password.erl new file mode 100755 index 0000000..44089ba --- /dev/null +++ b/src/handlers/handler_reset_password.erl @@ -0,0 +1,76 @@ +%%%------------------------------------------------------------------- +%%% @doc POST /v1/reset-password — установка нового пароля по токену. +%%% @end +%%%------------------------------------------------------------------- +-module(handler_reset_password). +-behaviour(cowboy_handler). +-export([init/2]). +-export([trails/0]). + +init(Req, _Opts) -> + case cowboy_req:method(Req) of + <<"POST">> -> reset(Req); + _ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>) + end. + +reset(Req) -> + case cowboy_req:has_body(Req) of + false -> + handler_utils:send_error(Req, 400, <<"Missing request body">>); + true -> + {ok, Body, Req1} = cowboy_req:read_body(Req), + try jsx:decode(Body, [return_maps]) of + #{<<"token">> := Token, <<"password">> := Password} + when is_binary(Token), is_binary(Password) -> + case logic_password_reset:reset_password(Token, Password) of + ok -> + handler_utils:send_json(Req1, 200, #{<<"message">> => <<"Password updated">>}); + {error, expired} -> + handler_utils:send_error(Req1, 410, <<"Token expired">>); + {error, not_found} -> + handler_utils:send_error(Req1, 404, <<"Token not found">>); + {error, invalid_password} -> + handler_utils:send_error(Req1, 400, <<"Invalid password">>); + {error, forbidden} -> + handler_utils:send_error(Req1, 403, <<"Account cannot reset password">>); + {error, _} -> + handler_utils:send_error(Req1, 500, <<"Internal server error">>) + end; + _ -> + handler_utils:send_error(Req1, 400, <<"Missing token or password">>) + catch + _:_ -> handler_utils:send_error(Req1, 400, <<"Invalid JSON">>) + end + end. + +trails() -> + [ + #{ + path => <<"/v1/reset-password">>, + method => <<"POST">>, + description => <<"Reset password using token from email">>, + tags => [<<"Auth">>], + requestBody => #{ + required => true, + content => #{ + <<"application/json">> => #{ + schema => #{ + type => object, + required => [<<"token">>, <<"password">>], + properties => #{ + token => #{type => string}, + password => #{type => string, format => <<"password">>, minLength => 8} + } + } + } + } + }, + responses => #{ + 200 => #{description => <<"Password updated">>}, + 400 => #{description => <<"Missing fields or invalid password">>}, + 403 => #{description => <<"Account not eligible">>}, + 404 => #{description => <<"Token not found">>}, + 410 => #{description => <<"Token expired">>} + } + } + ]. diff --git a/src/infra/infra_mnesia.erl b/src/infra/infra_mnesia.erl index f32aa91..1ac0f19 100755 --- a/src/infra/infra_mnesia.erl +++ b/src/infra/infra_mnesia.erl @@ -13,7 +13,7 @@ terminate/2, code_change/3]). -define(TABLES, [ - user, session, verification, admin, admin_session, auth_session, + user, session, verification, password_reset, admin, admin_session, auth_session, calendar, calendar_share, calendar_follow, calendar_specialist, specialist_invite, event, recurrence_exception, booking, @@ -23,9 +23,9 @@ stats_counter, stats_daily, node_metric, schema_migration ]). --define(DISC_TABLES, ?TABLES -- [session, verification, admin_session, node_metric]). +-define(DISC_TABLES, ?TABLES -- [session, verification, password_reset, admin_session, node_metric]). %% ram_copies: joining nodes must add_table_copy — create_table already_exists skips it. --define(RAM_TABLES, [session, verification, admin_session]). +-define(RAM_TABLES, [session, verification, password_reset, admin_session]). %% Disc load on IFT after crash-loop can exceed default gen_server:call 5s. -define(TABLE_WAIT_TIMEOUT, 120000). -define(CLEANUP_INTERVAL, 30000). % 30 секунд @@ -338,6 +338,7 @@ table_opts(stats_daily) -> [{disc_copies, [node()]}, {attributes, record_info(fi table_opts(schema_migration) -> [{disc_copies, [node()]}, {attributes, record_info(fields, schema_migration)}]; table_opts(session) -> [{ram_copies, [node()]}, {attributes, record_info(fields, session)}]; table_opts(verification) -> [{ram_copies, [node()]}, {attributes, record_info(fields, verification)}]; +table_opts(password_reset) -> [{ram_copies, [node()]}, {attributes, record_info(fields, password_reset)}]; table_opts(admin_session) -> [{ram_copies, [node()]}, {attributes, record_info(fields, admin_session)}]; table_opts(auth_session) -> [{disc_copies, [node()]}, {attributes, record_info(fields, auth_session)}]; table_opts(node_metric) -> [{disc_copies, [node()]}, {local_content, true}, {attributes, record_info(fields, node_metric)}]. @@ -374,6 +375,7 @@ create_indices() -> mnesia:add_table_index(user, nickname), mnesia:add_table_index(user, email), mnesia:add_table_index(verification, user_id), + mnesia:add_table_index(password_reset, user_id), mnesia:add_table_index(notification, user_id), mnesia:add_table_index(notification, is_read), mnesia:add_table_index(auth_session, family_id), diff --git a/src/logic/logic_email.erl b/src/logic/logic_email.erl index eb3debc..0cd4d94 100755 --- a/src/logic/logic_email.erl +++ b/src/logic/logic_email.erl @@ -1,8 +1,11 @@ -module(logic_email). --export([send_verification_email/2, send_specialist_invite/2]). +-export([send_verification_email/2, send_specialist_invite/2, send_password_reset/2]). send_verification_email(Email, Token) -> io:format("Sending verification email to ~s with token ~s~n", [Email, Token]). send_specialist_invite(Email, Token) -> io:format("Sending specialist invite email to ~s with token ~s~n", [Email, Token]). + +send_password_reset(Email, Token) -> + io:format("Sending password reset email to ~s with token ~s~n", [Email, Token]). diff --git a/src/logic/logic_password_reset.erl b/src/logic/logic_password_reset.erl new file mode 100755 index 0000000..1f47b3e --- /dev/null +++ b/src/logic/logic_password_reset.erl @@ -0,0 +1,61 @@ +-module(logic_password_reset). +-include("records.hrl"). +-export([request_reset/1, reset_password/2]). + +-define(MIN_PASSWORD_LEN, 8). + +%%%------------------------------------------------------------------- +%%% @doc Запрос сброса пароля. Всегда `{ok, sent}` — без enumeration. +%%% Письмо (stub) уходит только для `active` пользователей. +%%% @end +%%%------------------------------------------------------------------- +-spec request_reset(Email :: binary()) -> {ok, sent}. +request_reset(Email) when is_binary(Email) -> + case core_user:get_by_email(Email) of + {ok, #user{id = UserId, status = active, email = UserEmail}} -> + {ok, Token, _Expires} = core_password_reset:create_token(UserId), + logic_email:send_password_reset(UserEmail, Token), + {ok, sent}; + _ -> + {ok, sent} + end; +request_reset(_) -> + {ok, sent}. + +%%%------------------------------------------------------------------- +%%% @doc Установка нового пароля по токену. Отзывает refresh-сессии user. +%%% @end +%%%------------------------------------------------------------------- +-spec reset_password(Token :: binary(), Password :: binary()) -> + ok | {error, expired | not_found | invalid_password | password_hash_failed | user_not_found | forbidden}. +reset_password(Token, Password) + when is_binary(Token), is_binary(Password), byte_size(Password) >= ?MIN_PASSWORD_LEN -> + case core_password_reset:verify_token(Token) of + {ok, UserId} -> + case core_user:get_by_id(UserId) of + {ok, #user{status = active}} -> + case logic_auth:hash_password(Password) of + {ok, NewHash} -> + case core_user:update(UserId, [{password_hash, NewHash}]) of + {ok, _} -> + core_password_reset:delete_token(Token), + core_auth_session:revoke_all_for_subject(UserId, user), + ok; + {error, not_found} -> + {error, user_not_found}; + {error, _} = Err -> + Err + end; + {error, _} -> + {error, password_hash_failed} + end; + {ok, #user{}} -> + {error, forbidden}; + {error, not_found} -> + {error, user_not_found} + end; + {error, _} = Err -> + Err + end; +reset_password(_, _) -> + {error, invalid_password}. diff --git a/src/swagger/eventhub_trails.erl b/src/swagger/eventhub_trails.erl index e0cf0fa..7ff41cb 100755 --- a/src/swagger/eventhub_trails.erl +++ b/src/swagger/eventhub_trails.erl @@ -18,6 +18,7 @@ admin() -> admin_handler_users, admin_handler_user_by_id, admin_handler_user_verification_token, + admin_handler_user_password_reset_token, admin_handler_user_stats, % ================== КАЛЕНДАРИ ================== admin_handler_calendars, @@ -69,6 +70,8 @@ user() -> handler_login, handler_refresh, handler_verify, + handler_forgot_password, + handler_reset_password, handler_booking_by_id, handler_bookings, handler_calendar_by_id, diff --git a/test/api/users/user_password_reset_tests.erl b/test/api/users/user_password_reset_tests.erl new file mode 100755 index 0000000..9c7574b --- /dev/null +++ b/test/api/users/user_password_reset_tests.erl @@ -0,0 +1,60 @@ +%%%------------------------------------------------------------------- +%%% @doc API-тесты forgot/reset password. +%%% @end +%%%------------------------------------------------------------------- +-module(user_password_reset_tests). +-include_lib("eunit/include/eunit.hrl"). +-export([test/0]). + +-spec test() -> ok. +test() -> + ct:pal("=== User Password Reset Tests ==="), + AdminToken = api_test_runner:get_admin_token(), + Email = api_test_runner:unique_email(<<"pwreset">>), + OldPass = <<"OldPass123">>, + NewPass = <<"NewPass456">>, + + %% register + verify + {ok, 201, _, RegBody} = api_test_runner:client_request(post, <<"/v1/register">>, <<>>, + jsx:encode(#{email => Email, password => OldPass})), + #{<<"user">> := #{<<"id">> := UserId}} = + jsx:decode(list_to_binary(RegBody), [return_maps]), + #{<<"token">> := VerifyToken} = api_test_runner:admin_get( + <<"/v1/admin/users/", UserId/binary, "/verification-token">>, AdminToken), + {ok, 200, _, _} = api_test_runner:client_request(post, <<"/v1/verify">>, <<>>, + jsx:encode(#{<<"token">> => VerifyToken})), + + %% forgot always 200 (known + unknown) + {ok, 200, _, _} = api_test_runner:client_request(post, <<"/v1/forgot-password">>, <<>>, + jsx:encode(#{email => Email})), + {ok, 200, _, _} = api_test_runner:client_request(post, <<"/v1/forgot-password">>, <<>>, + jsx:encode(#{email => <<"nobody-reset@test.local">>})), + + %% admin token + reset + #{<<"token">> := ResetToken} = api_test_runner:admin_get( + <<"/v1/admin/users/", UserId/binary, "/password-reset-token">>, AdminToken), + {ok, 200, _, _} = api_test_runner:client_request(post, <<"/v1/reset-password">>, <<>>, + jsx:encode(#{<<"token">> => ResetToken, <<"password">> => NewPass})), + + %% old password fails, new works + {ok, 401, _, _} = api_test_runner:client_request(post, <<"/v1/login">>, <<>>, + jsx:encode(#{email => Email, password => OldPass})), + {ok, 200, _, _} = api_test_runner:client_request(post, <<"/v1/login">>, <<>>, + jsx:encode(#{email => Email, password => NewPass})), + + %% token reuse → 404 + {ok, 404, _, _} = api_test_runner:client_request(post, <<"/v1/reset-password">>, <<>>, + jsx:encode(#{<<"token">> => ResetToken, <<"password">> => <<"AnotherPass9">>})), + + %% short password → 400 + #{<<"token">> := ResetToken2} = api_test_runner:admin_get( + <<"/v1/admin/users/", UserId/binary, "/password-reset-token">>, AdminToken), + {ok, 400, _, _} = api_test_runner:client_request(post, <<"/v1/reset-password">>, <<>>, + jsx:encode(#{<<"token">> => ResetToken2, <<"password">> => <<"short">>})), + + %% invalid token → 404 + {ok, 404, _, _} = api_test_runner:client_request(post, <<"/v1/reset-password">>, <<>>, + jsx:encode(#{<<"token">> => <<"nope">>, <<"password">> => NewPass})), + + ct:pal("=== All user password reset tests passed ==="), + ok. diff --git a/test/api_users_SUITE.erl b/test/api_users_SUITE.erl index ebbed59..f8ebca1 100755 --- a/test/api_users_SUITE.erl +++ b/test/api_users_SUITE.erl @@ -26,6 +26,7 @@ all() -> [ user_test_verification, + user_test_password_reset, user_test_register, user_test_login, user_test_user_me, @@ -99,6 +100,9 @@ end_per_suite(Config) -> user_test_verification(_Config) -> user_verification_tests:test(). +user_test_password_reset(_Config) -> + user_password_reset_tests:test(). + user_test_register(_Config) -> user_register_tests:test(). diff --git a/test/unit/admin_handler_user_password_reset_token_tests.erl b/test/unit/admin_handler_user_password_reset_token_tests.erl new file mode 100755 index 0000000..89f9d1e --- /dev/null +++ b/test/unit/admin_handler_user_password_reset_token_tests.erl @@ -0,0 +1,83 @@ +-module(admin_handler_user_password_reset_token_tests). +-include_lib("eunit/include/eunit.hrl"). +-include("records.hrl"). + +-define(TABLES, [admin, admin_audit, user, password_reset, ticket]). +-define(ADMIN_ID, <<"adm_pwreset_1">>). + +setup() -> + eh_test_support:start_mnesia(), + eh_test_support:ensure_tables(?TABLES), + eh_test_support:ensure_jwt(), + eh_test_support:seed_admin(#{id => ?ADMIN_ID}), + ok. + +cleanup(_) -> + eh_test_support:unload_cowboy(), + eh_test_support:delete_tables(?TABLES), + eh_test_support:stop_mnesia(), + ok. + +admin_user_password_reset_token_test_() -> + {foreach, fun setup/0, fun cleanup/1, [ + {"GET – create token", {timeout, 60, fun test_create/0}}, + {"GET – reuse valid token", {timeout, 60, fun test_reuse/0}}, + {"GET – user not found", {timeout, 60, fun test_not_found/0}}, + {"GET – unauthorized", {timeout, 60, fun test_unauthorized/0}} + ]}. + +seed_user() -> + User = eh_test_support:make_user(#{ + id => <<"u_pwreset">>, + email => <<"pwreset@test.local">>, + nickname => <<"pwreset">>, + status => active + }), + ok = mnesia:dirty_write(User), + User. + +test_create() -> + User = seed_user(), + Id = User#user.id, + {Status, _, Body} = eh_test_support:call(admin_handler_user_password_reset_token, #{ + method => <<"GET">>, + path => <<"/v1/admin/users/", Id/binary, "/password-reset-token">>, + bindings => #{id => Id}, + auth => ?ADMIN_ID + }), + ?assertEqual(200, Status), + Result = jsx:decode(Body, [return_maps]), + ?assert(is_binary(maps:get(<<"token">>, Result))), + ?assert(is_binary(maps:get(<<"expires_at">>, Result))). + +test_reuse() -> + User = seed_user(), + Id = User#user.id, + {ok, Token1, _} = core_password_reset:create_token(Id), + {Status, _, Body} = eh_test_support:call(admin_handler_user_password_reset_token, #{ + method => <<"GET">>, + path => <<"/v1/admin/users/", Id/binary, "/password-reset-token">>, + bindings => #{id => Id}, + auth => ?ADMIN_ID + }), + ?assertEqual(200, Status), + Result = jsx:decode(Body, [return_maps]), + ?assertEqual(Token1, maps:get(<<"token">>, Result)). + +test_not_found() -> + {Status, _, _} = eh_test_support:call(admin_handler_user_password_reset_token, #{ + method => <<"GET">>, + path => <<"/v1/admin/users/missing/password-reset-token">>, + bindings => #{id => <<"missing">>}, + auth => ?ADMIN_ID + }), + ?assertEqual(404, Status). + +test_unauthorized() -> + {Status, _, _} = eh_test_support:call(admin_handler_user_password_reset_token, #{ + method => <<"GET">>, + path => <<"/v1/admin/users/x/password-reset-token">>, + bindings => #{id => <<"x">>}, + auth => none + }), + ?assertEqual(401, Status). diff --git a/test/unit/eh_test_support.erl b/test/unit/eh_test_support.erl index e05a181..a2075c7 100755 --- a/test/unit/eh_test_support.erl +++ b/test/unit/eh_test_support.erl @@ -168,6 +168,8 @@ table_opts(session) -> [{ram_copies, [node()]}, {attributes, record_info(fields, session)}]; table_opts(verification) -> [{ram_copies, [node()]}, {attributes, record_info(fields, verification)}]; +table_opts(password_reset) -> + [{ram_copies, [node()]}, {attributes, record_info(fields, password_reset)}]; table_opts(admin_session) -> [{ram_copies, [node()]}, {attributes, record_info(fields, admin_session)}]; table_opts(auth_session) -> diff --git a/test/unit/logic_password_reset_tests.erl b/test/unit/logic_password_reset_tests.erl new file mode 100755 index 0000000..4d774fa --- /dev/null +++ b/test/unit/logic_password_reset_tests.erl @@ -0,0 +1,82 @@ +-module(logic_password_reset_tests). +-include_lib("eunit/include/eunit.hrl"). +-include("records.hrl"). + +-define(TABLES, [user, password_reset, auth_session]). + +setup() -> + eh_test_support:start_mnesia(), + eh_test_support:ensure_tables(?TABLES), + catch ets:new(eventhub_counters, [named_table, public, set, {write_concurrency, true}]), + ok. + +cleanup(_) -> + catch ets:delete(eventhub_counters), + eh_test_support:delete_tables(?TABLES), + eh_test_support:stop_mnesia(), + ok. + +logic_password_reset_test_() -> + {foreach, fun setup/0, fun cleanup/1, [ + {"request_reset – active sends token", {timeout, 60, fun test_request_active/0}}, + {"request_reset – pending silent", {timeout, 60, fun test_request_pending/0}}, + {"reset_password – happy path + revoke sessions", {timeout, 60, fun test_reset_ok/0}}, + {"reset_password – expired", {timeout, 60, fun test_reset_expired/0}}, + {"reset_password – short password", {timeout, 60, fun test_short_password/0}} + ]}. + +seed_active() -> + {ok, Hash} = logic_auth:hash_password(<<"OldPass123">>), + User = eh_test_support:make_user(#{ + id => <<"u_active_pw">>, + email => <<"active-pw@test.local">>, + status => active, + password_hash => Hash + }), + ok = mnesia:dirty_write(User), + User. + +seed_pending() -> + User = eh_test_support:make_user(#{ + id => <<"u_pending_pw">>, + email => <<"pending-pw@test.local">>, + status => pending + }), + ok = mnesia:dirty_write(User), + User. + +test_request_active() -> + User = seed_active(), + ?assertEqual({ok, sent}, logic_password_reset:request_reset(User#user.email)), + Matches = mnesia:dirty_match_object(#password_reset{user_id = User#user.id, _ = '_'}), + ?assertMatch([_], Matches). + +test_request_pending() -> + User = seed_pending(), + ?assertEqual({ok, sent}, logic_password_reset:request_reset(User#user.email)), + Matches = mnesia:dirty_match_object(#password_reset{user_id = User#user.id, _ = '_'}), + ?assertEqual([], Matches). + +test_reset_ok() -> + User = seed_active(), + {ok, Session} = core_auth_session:create(User#user.id, user, <<"web">>), + {ok, Token, _} = core_password_reset:create_token(User#user.id), + ?assertEqual(ok, logic_password_reset:reset_password(Token, <<"NewPass456">>)), + {ok, Updated} = core_user:get_by_id(User#user.id), + ?assertMatch({ok, true}, logic_auth:verify_password(<<"NewPass456">>, Updated#user.password_hash)), + {ok, Sess2} = core_auth_session:get(Session#auth_session.session_id), + ?assertEqual(true, Sess2#auth_session.revoked), + ?assertEqual({error, not_found}, core_password_reset:verify_token(Token)). + +test_reset_expired() -> + User = seed_active(), + Past = calendar:gregorian_seconds_to_datetime( + calendar:datetime_to_gregorian_seconds(calendar:universal_time()) - 10), + Token = <<"expired_tok">>, + mnesia:dirty_write(#password_reset{token = Token, user_id = User#user.id, expires_at = Past}), + ?assertEqual({error, expired}, logic_password_reset:reset_password(Token, <<"NewPass456">>)). + +test_short_password() -> + User = seed_active(), + {ok, Token, _} = core_password_reset:create_token(User#user.id), + ?assertEqual({error, invalid_password}, logic_password_reset:reset_password(Token, <<"short">>)).