From 4224da1a22d2583c41beda64ef677a935357540e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B5=D0=B9=20=D0=A1=D0=B0?= =?UTF-8?q?=D0=B1=D0=B8=D0=BB=D0=B8=D0=BD?= Date: Mon, 20 Apr 2026 10:28:53 +0300 Subject: [PATCH] Stage 2 --- .gitignore | 1 + rebar.config | 2 +- src/core/core_user.erl | 93 +++++++++++++++++++++++++++++ src/eventhub_app.erl | 38 +++++++++++- src/handlers/handler_health.erl | 5 +- src/handlers/handler_login.erl | 67 +++++++++++++++++++++ src/handlers/handler_refresh.erl | 91 ++++++++++++++++++++++++++++ src/handlers/handler_register.erl | 48 +++++++++++++++ src/handlers/handler_user_me.erl | 57 ++++++++++++++++++ src/logic/logic_auth.erl | 88 +++++++++++++++++++++++++++ src/middlewares/middleware_auth.erl | 36 +++++++++++ 11 files changed, 520 insertions(+), 6 deletions(-) create mode 100644 src/core/core_user.erl create mode 100644 src/handlers/handler_login.erl create mode 100644 src/handlers/handler_refresh.erl create mode 100644 src/handlers/handler_register.erl create mode 100644 src/handlers/handler_user_me.erl create mode 100644 src/logic/logic_auth.erl create mode 100644 src/middlewares/middleware_auth.erl diff --git a/.gitignore b/.gitignore index 46f9f2b..62eea9a 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,4 @@ logs rebar3.crashdump *~ /.tool-versions +/rebar.lock diff --git a/rebar.config b/rebar.config index 9138231..68c9a44 100644 --- a/rebar.config +++ b/rebar.config @@ -1,4 +1,4 @@ -{erl_opts, [debug_info]}. +{erl_opts, [debug_info, {i, "include"}]}. {deps, [ {cowboy, "2.10.0"}, {jsx, "3.1.0"}, diff --git a/src/core/core_user.erl b/src/core/core_user.erl new file mode 100644 index 0000000..3e179ff --- /dev/null +++ b/src/core/core_user.erl @@ -0,0 +1,93 @@ +-module(core_user). + +-include("records.hrl"). + +-export([create/2, get_by_id/1, get_by_email/1, update/2, delete/1]). +-export([email_exists/1]). + +%% Создание пользователя +create(Email, Password) -> + % Сначала проверяем, существует ли email + case email_exists(Email) of + true -> + {error, email_exists}; + false -> + Id = generate_id(), + {ok, PasswordHash} = logic_auth:hash_password(Password), + User = #user{ + id = Id, + email = Email, + password_hash = PasswordHash, + role = user, + status = active, + created_at = calendar:universal_time(), + updated_at = calendar:universal_time() + }, + + F = fun() -> + mnesia:write(User), + {ok, User} + end, + + case mnesia:transaction(F) of + {atomic, Result} -> Result; + {aborted, Reason} -> {error, Reason} + end + end. + +%% Получение пользователя по ID +get_by_id(Id) -> + case mnesia:dirty_read(user, Id) of + [] -> {error, not_found}; + [User] -> {ok, User} + end. + +%% Получение пользователя по email (через индекс позже) +get_by_email(Email) -> + Match = #user{email = Email, _ = '_'}, + case mnesia:dirty_match_object(Match) of + [] -> {error, not_found}; + [User] -> {ok, User} + end. + +%% Проверка существования email +email_exists(Email) -> + case get_by_email(Email) of + {ok, _} -> true; + {error, _} -> false + end. + +%% Обновление пользователя +update(Id, Updates) -> + F = fun() -> + case mnesia:read(user, Id) of + [] -> + {error, not_found}; + [User] -> + UpdatedUser = apply_updates(User, Updates), + mnesia:write(UpdatedUser), + {ok, UpdatedUser} + end + end, + + case mnesia:transaction(F) of + {atomic, Result} -> Result; + {aborted, Reason} -> {error, Reason} + end. + +%% Удаление пользователя (soft delete) +delete(Id) -> + update(Id, [{status, deleted}]). + +%% Внутренние функции +generate_id() -> + base64:encode(crypto:strong_rand_bytes(16)). + +apply_updates(User, Updates) -> + lists:foldl(fun({Field, Value}, U) -> set_field(Field, Value, U) end, User, Updates). + +set_field(email, Value, User) -> User#user{email = Value, updated_at = calendar:universal_time()}; +set_field(password_hash, Value, User) -> User#user{password_hash = Value, updated_at = calendar:universal_time()}; +set_field(role, Value, User) -> User#user{role = Value, updated_at = calendar:universal_time()}; +set_field(status, Value, User) -> User#user{status = Value, updated_at = calendar:universal_time()}; +set_field(_, _, User) -> User. \ No newline at end of file diff --git a/src/eventhub_app.erl b/src/eventhub_app.erl index 04d9940..4261b9c 100644 --- a/src/eventhub_app.erl +++ b/src/eventhub_app.erl @@ -1,4 +1,3 @@ -%% Основной модуль приложения -module(eventhub_app). -behaviour(application). @@ -7,6 +6,7 @@ start(_StartType, _StartArgs) -> % Запускаем Mnesia application:ensure_all_started(mnesia), + application:ensure_all_started(cowboy), case infra_sup:start_link() of {ok, Pid} -> @@ -14,11 +14,43 @@ start(_StartType, _StartArgs) -> ok = infra_mnesia:init_tables(), ok = infra_mnesia:wait_for_tables(), - % Здесь позже запустим HTTP-сервер и другие сервисы + % Запускаем HTTP-сервер + start_http(), + {ok, Pid}; Error -> Error end. stop(_State) -> - ok. \ No newline at end of file + ok. + +%% Internal functions +start_http() -> + Port = application:get_env(eventhub, http_port, 8080), + + % Настройка маршрутов + Dispatch = cowboy_router:compile([ + {'_', [ + {"/health", handler_health, []}, + {"/v1/register", handler_register, []}, + {"/v1/login", handler_login, []}, + {"/v1/refresh", handler_refresh, []}, + {"/v1/user/me", handler_user_me, []} + ]} + ]), + + % Настройка middleware + Middlewares = [ + cowboy_router, + cowboy_handler + ], + + Env = #{dispatch => Dispatch}, + + cowboy:start_clear(http, [{port, Port}], #{ + env => Env, + middlewares => Middlewares + }), + + io:format("HTTP server started on port ~p~n", [Port]). \ No newline at end of file diff --git a/src/handlers/handler_health.erl b/src/handlers/handler_health.erl index 946b013..79f89cf 100644 --- a/src/handlers/handler_health.erl +++ b/src/handlers/handler_health.erl @@ -1,6 +1,7 @@ -module(handler_health). + -export([init/2]). init(Req, _Opts) -> - {ok, Resp} = cowboy_req:reply(200, #{<<"content-type">> => <<"application/json">>}, <<"{\"status\":\"ok\"}">>, Req), - Resp. \ No newline at end of file + Body = jsx:encode(#{status => <<"ok">>}), + cowboy_req:reply(200, #{<<"content-type">> => <<"application/json">>}, Body, Req). \ No newline at end of file diff --git a/src/handlers/handler_login.erl b/src/handlers/handler_login.erl new file mode 100644 index 0000000..dfa9bb3 --- /dev/null +++ b/src/handlers/handler_login.erl @@ -0,0 +1,67 @@ +-module(handler_login). +-include("records.hrl"). + +-export([init/2]). + +init(Req, Opts) -> + handle(Req, Opts). + +handle(Req, _Opts) -> + case cowboy_req:method(Req) of + <<"POST">> -> + {ok, Body, Req1} = cowboy_req:read_body(Req), + case jsx:decode(Body, [return_maps]) of + #{<<"email">> := Email, <<"password">> := Password} -> + case core_user:get_by_email(Email) of + {ok, User} -> + case logic_auth:verify_password(Password, User#user.password_hash) of + {ok, true} -> + case User#user.status of + active -> + Token = logic_auth:generate_jwt(User#user.id, User#user.role), + {RefreshToken, ExpiresAt} = logic_auth:generate_refresh_token(User#user.id), + save_refresh_token(User#user.id, RefreshToken, ExpiresAt), + Response = #{ + user => #{ + id => User#user.id, + email => User#user.email, + role => User#user.role + }, + token => Token, + refresh_token => RefreshToken + }, + send_json(Req1, 200, Response); + frozen -> + send_error(Req1, 403, <<"Account frozen">>); + deleted -> + send_error(Req1, 403, <<"Account deleted">>) + end; + _ -> + send_error(Req1, 401, <<"Invalid credentials">>) + end; + {error, not_found} -> + send_error(Req1, 401, <<"Invalid credentials">>) + end; + _ -> + send_error(Req1, 400, <<"Invalid request body">>) + end; + _ -> + send_error(Req, 405, <<"Method not allowed">>) + end. + +save_refresh_token(UserId, Token, ExpiresAt) -> + Session = #session{ + token = Token, + user_id = UserId, + expires_at = ExpiresAt, + type = refresh + }, + mnesia:dirty_write(Session). + +send_json(Req, Status, Data) -> + Body = jsx:encode(Data), + cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req). + +send_error(Req, Status, Message) -> + Body = jsx:encode(#{error => Message}), + cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req). \ No newline at end of file diff --git a/src/handlers/handler_refresh.erl b/src/handlers/handler_refresh.erl new file mode 100644 index 0000000..abeff8f --- /dev/null +++ b/src/handlers/handler_refresh.erl @@ -0,0 +1,91 @@ +-module(handler_refresh). +-include("records.hrl"). + +-export([init/2]). + +init(Req, Opts) -> + handle(Req, Opts). + +handle(Req, _Opts) -> + case cowboy_req:method(Req) of + <<"POST">> -> + {ok, Body, Req1} = cowboy_req:read_body(Req), + case jsx:decode(Body, [return_maps]) of + #{<<"refresh_token">> := RefreshToken} -> + case validate_refresh_token(RefreshToken) of + {ok, UserId} -> + case core_user:get_by_id(UserId) of + {ok, User} -> + % Генерируем новые токены + NewToken = logic_auth:generate_jwt(User#user.id, User#user.role), + {NewRefreshToken, ExpiresAt} = logic_auth:generate_refresh_token(User#user.id), + + % Сохраняем новый refresh token + save_refresh_token(User#user.id, NewRefreshToken, ExpiresAt), + + % Удаляем старый refresh token + delete_refresh_token(RefreshToken), + + Response = #{ + token => NewToken, + refresh_token => NewRefreshToken + }, + send_json(Req1, 200, Response); + {error, not_found} -> + send_error(Req1, 401, <<"User not found">>) + end; + {error, expired} -> + send_error(Req1, 401, <<"Refresh token expired">>); + {error, invalid} -> + send_error(Req1, 401, <<"Invalid refresh token">>) + end; + _ -> + send_error(Req1, 400, <<"Missing refresh_token">>) + end; + _ -> + send_error(Req, 405, <<"Method not allowed">>) + end. + +validate_refresh_token(Token) -> + case get_session_by_token(Token) of + {ok, Session} -> + % Проверяем срок действия + Now = calendar:universal_time(), + case Session#session.expires_at > Now of + true -> {ok, Session#session.user_id}; + false -> {error, expired} + end; + {error, not_found} -> + {error, invalid} + end. + +get_session_by_token(Token) -> + Match = #session{token = Token, type = refresh, _ = '_'}, + case mnesia:dirty_match_object(Match) of + [] -> {error, not_found}; + [Session] -> {ok, Session} + end. + +save_refresh_token(UserId, Token, ExpiresAt) -> + Session = #session{ + token = Token, + user_id = UserId, + expires_at = ExpiresAt, + type = refresh + }, + mnesia:dirty_write(Session). + +delete_refresh_token(Token) -> + Match = #session{token = Token, type = refresh, _ = '_'}, + case mnesia:dirty_match_object(Match) of + [] -> ok; + [Session] -> mnesia:dirty_delete_object(Session) + end. + +send_json(Req, Status, Data) -> + Body = jsx:encode(Data), + cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req). + +send_error(Req, Status, Message) -> + Body = jsx:encode(#{error => Message}), + cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req). \ No newline at end of file diff --git a/src/handlers/handler_register.erl b/src/handlers/handler_register.erl new file mode 100644 index 0000000..3ad3ee8 --- /dev/null +++ b/src/handlers/handler_register.erl @@ -0,0 +1,48 @@ +-module(handler_register). +-include("records.hrl"). + +-export([init/2]). + +init(Req, Opts) -> + handle(Req, Opts). + +handle(Req, _Opts) -> + case cowboy_req:method(Req) of + <<"POST">> -> + {ok, Body, Req1} = cowboy_req:read_body(Req), + case jsx:decode(Body, [return_maps]) of + #{<<"email">> := Email, <<"password">> := Password} -> + case core_user:email_exists(Email) of + true -> + send_error(Req1, 409, <<"Email already exists">>); + false -> + case core_user:create(Email, Password) of + {ok, User} -> + Token = logic_auth:generate_jwt(User#user.id, User#user.role), + Response = #{ + user => #{ + id => User#user.id, + email => User#user.email, + role => User#user.role + }, + token => Token + }, + send_json(Req1, 201, Response); + {error, _} -> + send_error(Req1, 500, <<"Internal server error">>) + end + end; + _ -> + send_error(Req1, 400, <<"Invalid request body">>) + end; + _ -> + send_error(Req, 405, <<"Method not allowed">>) + end. + +send_json(Req, Status, Data) -> + Body = jsx:encode(Data), + cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req). + +send_error(Req, Status, Message) -> + Body = jsx:encode(#{error => Message}), + cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req). \ No newline at end of file diff --git a/src/handlers/handler_user_me.erl b/src/handlers/handler_user_me.erl new file mode 100644 index 0000000..370877b --- /dev/null +++ b/src/handlers/handler_user_me.erl @@ -0,0 +1,57 @@ +-module(handler_user_me). +-include("records.hrl"). + +-export([init/2]). + +init(Req, Opts) -> + handle(Req, Opts). + +handle(Req, _Opts) -> + case cowboy_req:method(Req) of + <<"GET">> -> + case authenticate(Req) of + {ok, UserId, Req1} -> + case core_user:get_by_id(UserId) of + {ok, User} -> + Response = #{ + id => User#user.id, + email => User#user.email, + role => User#user.role, + status => User#user.status, + created_at => User#user.created_at, + updated_at => User#user.updated_at + }, + send_json(Req1, 200, Response); + {error, not_found} -> + send_error(Req1, 404, <<"User not found">>) + end; + {error, Code, Message, Req1} -> + send_error(Req1, Code, Message) + end; + _ -> + send_error(Req, 405, <<"Method not allowed">>) + end. + +authenticate(Req) -> + case cowboy_req:parse_header(<<"authorization">>, Req) of + {bearer, Token} -> + case logic_auth:verify_jwt(Token) of + {ok, Claims} -> + UserId = maps:get(<<"user_id">>, Claims), + {ok, UserId, Req}; + {error, expired} -> + {error, 401, <<"Token expired">>, Req}; + {error, _} -> + {error, 401, <<"Invalid token">>, Req} + end; + _ -> + {error, 401, <<"Missing or invalid Authorization header">>, Req} + end. + +send_json(Req, Status, Data) -> + Body = jsx:encode(Data), + cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req). + +send_error(Req, Status, Message) -> + Body = jsx:encode(#{error => Message}), + cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req). \ No newline at end of file diff --git a/src/logic/logic_auth.erl b/src/logic/logic_auth.erl new file mode 100644 index 0000000..1eba2fb --- /dev/null +++ b/src/logic/logic_auth.erl @@ -0,0 +1,88 @@ +-module(logic_auth). + +-export([hash_password/1, verify_password/2]). +-export([generate_jwt/2, verify_jwt/1, extract_claims/1]). +-export([generate_refresh_token/1]). + +%% ============ Argon2 хеширование ============ +hash_password(Password) when is_binary(Password) -> + argon2:hash(Password). + +verify_password(Password, Hash) when is_binary(Password), is_binary(Hash) -> + argon2:verify(Password, Hash). + +%% ============ JWT с использованием jose ============ +get_jwt_secret() -> + <<"my-super-secret-key-for-jwt-32-bytes!">>. + +get_jwk() -> + jose_jwk:from_oct(get_jwt_secret()). + +generate_jwt(UserId, Role) -> + JWK = get_jwk(), + + ExpTime = os:system_time(seconds) + 86400, % 24 часа + Claims = #{ + <<"user_id">> => UserId, + <<"role">> => Role, + <<"exp">> => ExpTime, + <<"iat">> => os:system_time(seconds) + }, + + JWT = jose_jwt:sign(JWK, #{<<"alg">> => <<"HS256">>}, Claims), + {_, Token} = jose_jws:compact(JWT), + Token. + +verify_jwt(Token) when is_binary(Token) -> + try + JWK = get_jwk(), + case jose_jwt:verify(JWK, Token) of + {true, {jose_jwt, Claims}, _} -> + case check_expiry(Claims) of + true -> {ok, Claims}; + false -> {error, expired} + end; + {true, Claims, _} when is_map(Claims) -> + case check_expiry(Claims) of + true -> {ok, Claims}; + false -> {error, expired} + end; + {false, _, _} -> + {error, invalid_signature} + end + catch + _:_ -> {error, invalid_token} + end. + +extract_claims(Token) when is_binary(Token) -> + try + JWK = get_jwk(), + case jose_jwt:verify(JWK, Token) of + {true, {jose_jwt, Claims}, _} -> + {ok, Claims}; + {true, Claims, _} when is_map(Claims) -> + {ok, Claims}; + _ -> + {error, invalid_token} + end + catch + _:_ -> {error, invalid_token} + end. + +check_expiry(Claims) -> + case maps:find(<<"exp">>, Claims) of + {ok, Exp} when is_integer(Exp) -> + Exp > os:system_time(seconds); + _ -> + false + end. + +%% ============ Refresh Token ============ +generate_refresh_token(_UserId) -> + Token = base64:encode(crypto:strong_rand_bytes(32)), + ExpiresAt = calendar:universal_time_to_local_time( + calendar:gregorian_seconds_to_datetime( + calendar:datetime_to_gregorian_seconds(calendar:universal_time()) + 30 * 86400 + ) + ), + {Token, ExpiresAt}. \ No newline at end of file diff --git a/src/middlewares/middleware_auth.erl b/src/middlewares/middleware_auth.erl new file mode 100644 index 0000000..f2ba721 --- /dev/null +++ b/src/middlewares/middleware_auth.erl @@ -0,0 +1,36 @@ +-module(middleware_auth). +-behaviour(cowboy_middleware). + +-export([execute/2]). + +execute(Req, Env) -> + case authenticate(Req) of + {ok, UserId, Role} -> + % Добавляем user_id и role в env + NewReq = cowboy_req:set_resp_header(<<"X-User-Id">>, UserId, Req), + {ok, NewReq, [{user_id, UserId}, {role, Role} | Env]}; + {error, {Code, Message}} -> + send_error(Req, Code, Message) + end. + +authenticate(Req) -> + case cowboy_req:parse_header(<<"authorization">>, Req) of + {ok, <<"Bearer ", Token/binary>>, _} -> + case logic_auth:verify_jwt(Token) of + {ok, Claims} -> + UserId = maps:get(<<"user_id">>, Claims), + Role = maps:get(<<"role">>, Claims), + {ok, UserId, Role}; + {error, expired} -> + {error, {401, <<"Token expired">>}}; + {error, _} -> + {error, {401, <<"Invalid token">>}} + end; + _ -> + {error, {401, <<"Missing or invalid Authorization header">>}} + end. + +send_error(Req, Code, Message) -> + Body = jsx:encode(#{error => Message}), + NewReq = cowboy_req:reply(Code, #{<<"content-type">> => <<"application/json">>}, Body, Req), + {stop, NewReq}. \ No newline at end of file